Compare commits

...

5 commits

32 changed files with 526 additions and 415 deletions

View file

@ -18,7 +18,7 @@
- MSVC toolchain for Rust - MSVC toolchain for Rust
**Linux:** **Linux:**
- podman-compose - podman compose
### Quick Setup ### Quick Setup

View file

@ -44,7 +44,7 @@ cp compose/.env.production.example compose/.env.production
# 4. Deploy # 4. Deploy
cd compose cd compose
podman-compose --env-file .env.production -f production.yml up -d podman compose --env-file .env.production -f production.yml up -d
# 5. Access at http://localhost:4321 # 5. Access at http://localhost:4321
``` ```
@ -57,7 +57,7 @@ cp compose/.env.demo.example compose/.env.demo
# 2. Deploy # 2. Deploy
cd compose cd compose
podman-compose --env-file .env.demo -f demo.yml up -d podman compose --env-file .env.demo -f demo.yml up -d
# 3. Access at http://localhost:4322 # 3. Access at http://localhost:4322
``` ```
@ -105,8 +105,8 @@ To reset the demo to a clean state:
./scripts/demo-reset.sh ./scripts/demo-reset.sh
# Or manually: # Or manually:
podman-compose --env-file compose/.env.demo -f compose/demo.yml down -v podman compose --env-file compose/.env.demo -f compose/demo.yml down -v
podman-compose --env-file compose/.env.demo -f compose/demo.yml up -d podman compose --env-file compose/.env.demo -f compose/demo.yml up -d
``` ```
## Configuration Reference ## Configuration Reference
@ -141,7 +141,7 @@ For local development without containers:
```bash ```bash
# 1. Start only the database # 1. Start only the database
podman-compose -f compose/dev.yml up -d podman compose -f compose/dev.yml up -d
# 2. Configure backend environment # 2. Configure backend environment
cp backend/.env.example backend/.env cp backend/.env.example backend/.env
@ -159,10 +159,10 @@ npm run dev
```bash ```bash
# View all logs # View all logs
podman-compose --env-file compose/.env.demo -f compose/demo.yml logs -f podman compose --env-file compose/.env.demo -f compose/demo.yml logs -f
# View specific service # View specific service
podman-compose --env-file compose/.env.demo -f compose/demo.yml logs -f backend podman compose --env-file compose/.env.demo -f compose/demo.yml logs -f backend
# Check health # Check health
curl http://localhost:3001/health curl http://localhost:3001/health
@ -173,10 +173,10 @@ curl http://localhost:3001/health
### Database connection issues ### Database connection issues
```bash ```bash
# Check if postgres is running # Check if postgres is running
podman-compose --env-file compose/.env.demo -f compose/demo.yml ps podman compose --env-file compose/.env.demo -f compose/demo.yml ps
# View postgres logs # View postgres logs
podman-compose --env-file compose/.env.demo -f compose/demo.yml logs postgres podman compose --env-file compose/.env.demo -f compose/demo.yml logs postgres
``` ```
### Migration failures ### Migration failures
@ -194,6 +194,6 @@ SELECT * FROM _sqlx_migrations;
### Reset everything ### Reset everything
```bash ```bash
# Nuclear option - removes all data and volumes # Nuclear option - removes all data and volumes
podman-compose --env-file compose/.env.demo -f compose/demo.yml down -v podman compose --env-file compose/.env.demo -f compose/demo.yml down -v
podman-compose --env-file compose/.env.demo -f compose/demo.yml up -d podman compose --env-file compose/.env.demo -f compose/demo.yml up -d
``` ```

314
README.md
View file

@ -1,157 +1,157 @@
# Likwid - Modular Governance Platform # Likwid - Modular Governance Platform
## Composable Governance Infrastructure ## Composable Governance Infrastructure
A modular toolkit for deliberation, voting, delegation, moderation, and plugins — configure what you need per community. A modular toolkit for deliberation, voting, delegation, moderation, and plugins — configure what you need per community.
Likwid is an open-source platform for participatory governance. Assemble decision-making workflows from modular building blocks: deliberation, voting methods, delegation, moderation, and plugins. Likwid is an open-source platform for participatory governance. Assemble decision-making workflows from modular building blocks: deliberation, voting methods, delegation, moderation, and plugins.
> *"We are citizens of the 21st century, but we rely on institutions designed in the 19th century, through means designed in the 13th century. The problem is not democracy, it's the interface."* > *"We are citizens of the 21st century, but we rely on institutions designed in the 19th century, through means designed in the 13th century. The problem is not democracy, it's the interface."*
## Philosophy ## Philosophy
Likwid implements a set of principles for **modular governance infrastructure**: Likwid implements a set of principles for **modular governance infrastructure**:
- **Information must be understandable**, not just available - **Information must be understandable**, not just available
- **Listening matters more than speaking** — structured deliberation over flame wars - **Listening matters more than speaking** — structured deliberation over flame wars
- **Voting should express nuance** — from simple approval to Schulze and quadratic methods - **Voting should express nuance** — from simple approval to Schulze and quadratic methods
- **Delegation should be fluid** — trust networks that adapt in real-time - **Delegation should be fluid** — trust networks that adapt in real-time
- **Governance should be composable** — workflows assembled from modules, not imposed - **Governance should be composable** — workflows assembled from modules, not imposed
## Features ## Features
### Deliberative Democracy ### Deliberative Democracy
- **Inform → Discuss → Decide** workflow for proposals - **Inform → Discuss → Decide** workflow for proposals
- Resource libraries for informed participation - Resource libraries for informed participation
- Optional facilitator role on proposals - Optional facilitator role on proposals
- "Read before discuss" requirements - "Read before discuss" requirements
- Comment reactions for quality signals (agree/disagree/insightful/constructive/off-topic) - Comment reactions for quality signals (agree/disagree/insightful/constructive/off-topic)
### Advanced Voting Methods ### Advanced Voting Methods
- **Approval Voting** — vote for multiple options - **Approval Voting** — vote for multiple options
- **Ranked Choice** — order preferences - **Ranked Choice** — order preferences
- **Schulze Method** — Condorcet-consistent pairwise comparison - **Schulze Method** — Condorcet-consistent pairwise comparison
- **STAR Voting** — score + automatic runoff - **STAR Voting** — score + automatic runoff
- **Quadratic Voting** — express intensity of preference - **Quadratic Voting** — express intensity of preference
### Liquid Delegation ### Liquid Delegation
- Delegate your vote globally or within a community - Delegate your vote globally or within a community
- Real-time transparency: see how delegates vote - Real-time transparency: see how delegates vote
- Revoke delegation instantly - Revoke delegation instantly
- Transitive delegation chains - Transitive delegation chains
- Delegation analytics and trust networks - Delegation analytics and trust networks
### Modular Plugin System ### Modular Plugin System
- WASM-based sandboxed plugins - WASM-based sandboxed plugins
- Per-community plugin configuration - Per-community plugin configuration
- Hook-based architecture (actions/filters) - Hook-based architecture (actions/filters)
- Built-in and third-party plugins - Built-in and third-party plugins
- Admin policy for signed/unsigned plugins - Admin policy for signed/unsigned plugins
### Governance Infrastructure ### Governance Infrastructure
- Multi-community platform support - Multi-community platform support
- Granular admin controls (platform mode, registration, moderation) - Granular admin controls (platform mode, registration, moderation)
- Tamper-evident public moderation ledger - Tamper-evident public moderation ledger
- Role-based access (admin, moderator, facilitator, member) - Role-based access (admin, moderator, facilitator, member)
## Tech Stack ## Tech Stack
| Layer | Technology | | Layer | Technology |
| --- | --- | | --- | --- |
| **Backend** | Rust (Axum 0.8, Tokio, SQLx) | | **Backend** | Rust (Axum 0.8, Tokio, SQLx) |
| **Frontend** | Astro + TypeScript | | **Frontend** | Astro + TypeScript |
| **Database** | PostgreSQL 16 | | **Database** | PostgreSQL 16 |
| **Plugins** | WebAssembly (wasmtime) | | **Plugins** | WebAssembly (wasmtime) |
| **Containers** | Podman (rootless) | | **Containers** | Podman (rootless) |
## Quick Start ## Quick Start
### Prerequisites ### Prerequisites
**Windows:** **Windows:**
- Windows 10/11 with WSL2 - Windows 10/11 with WSL2
- Podman Desktop (WSL2 backend) - Podman Desktop (WSL2 backend)
- Rust (rustup, MSVC toolchain) - Rust (rustup, MSVC toolchain)
- Node.js LTS - Node.js LTS
**Linux:** **Linux:**
- Podman + podman-compose - Podman + podman compose
- Rust (rustup) - Rust (rustup)
- Node.js LTS - Node.js LTS
### Development ### Development
```powershell ```powershell
# 1. Clone and configure # 1. Clone and configure
git clone https://codeberg.org/likwid/likwid.git git clone https://codeberg.org/likwid/likwid.git
cd likwid cd likwid
$env:JWT_SECRET="dev_secret_change_me" $env:JWT_SECRET="dev_secret_change_me"
# 2. Start everything (database + backend + frontend) # 2. Start everything (database + backend + frontend)
.\scripts\dev-start.ps1 .\scripts\dev-start.ps1
# 3. Stop everything # 3. Stop everything
.\scripts\dev-stop.ps1 .\scripts\dev-stop.ps1
``` ```
The platform will be available at: The platform will be available at:
- **Frontend**: <http://localhost:4321> - **Frontend**: <http://localhost:4321>
- **Backend API**: <http://localhost:3000> - **Backend API**: <http://localhost:3000>
- **Setup Wizard**: <http://localhost:4321/setup> (first run) - **Setup Wizard**: <http://localhost:4321/setup> (first run)
### First Run ### First Run
1. Navigate to `/register` to create the first user (automatically becomes admin) 1. Navigate to `/register` to create the first user (automatically becomes admin)
2. Complete platform setup at `/setup` 2. Complete platform setup at `/setup`
3. Configure instance settings at `/admin/settings` 3. Configure instance settings at `/admin/settings`
4. Create your first community 4. Create your first community
## Project Structure ## Project Structure
```text ```text
likwid/ likwid/
├── backend/ # Rust backend ├── backend/ # Rust backend
│ ├── src/ │ ├── src/
│ │ ├── api/ # REST endpoints │ │ ├── api/ # REST endpoints
│ │ ├── auth/ # JWT authentication │ │ ├── auth/ # JWT authentication
│ │ ├── models/ # Database models │ │ ├── models/ # Database models
│ │ └── plugins/ # Plugin system (WASM + builtins) │ │ └── plugins/ # Plugin system (WASM + builtins)
│ └── migrations/ # SQL migrations │ └── migrations/ # SQL migrations
├── frontend/ # Astro frontend ├── frontend/ # Astro frontend
│ ├── src/ │ ├── src/
│ │ ├── pages/ # Routes │ │ ├── pages/ # Routes
│ │ ├── layouts/ # Page layouts │ │ ├── layouts/ # Page layouts
│ │ └── components/ # UI components │ │ └── components/ # UI components
├── compose/ # Podman compose files ├── compose/ # Podman compose files
├── scripts/ # Dev scripts (cross-platform) ├── scripts/ # Dev scripts (cross-platform)
└── docu_dev/ # Design documents └── docu_dev/ # Design documents
``` ```
### Core Principles ### Core Principles
1. **Be considerate** — Your work affects others 1. **Be considerate** — Your work affects others
2. **Be respectful** — Assume good intentions 2. **Be respectful** — Assume good intentions
3. **Be collaborative** — Work transparently 3. **Be collaborative** — Work transparently
4. **Be pragmatic** — Results over debates 4. **Be pragmatic** — Results over debates
5. **Find a third way** — Seek solutions that satisfy everyone 5. **Find a third way** — Seek solutions that satisfy everyone
## License ## License
EUPL-1.2 EUPL-1.2
## Acknowledgments ## Acknowledgments
Inspired by: Inspired by:
- [Pol.is](https://pol.is/) — Opinion mapping - [Pol.is](https://pol.is/) — Opinion mapping
- [Decidim](https://decidim.org/) — Participatory democracy - [Decidim](https://decidim.org/) — Participatory democracy
- [LiquidFeedback](https://liquidfeedback.org/) — Liquid democracy - [LiquidFeedback](https://liquidfeedback.org/) — Liquid democracy
- [Equal Vote Coalition](https://www.equal.vote/) — STAR Voting - [Equal Vote Coalition](https://www.equal.vote/) — STAR Voting

View file

@ -66,7 +66,7 @@ sqlx migrate run --source migrations_demo
```bash ```bash
# Uses separate database on port 5433, backend on 3001, frontend on 4322 # Uses separate database on port 5433, backend on 3001, frontend on 4322
cp compose/.env.demo.example compose/.env.demo cp compose/.env.demo.example compose/.env.demo
podman-compose --env-file compose/.env.demo -f compose/demo.yml up -d podman compose --env-file compose/.env.demo -f compose/demo.yml up -d
``` ```
### For real users (Production) ### For real users (Production)
@ -76,7 +76,7 @@ cp compose/.env.production.example compose/.env.production
# Edit with secure passwords and your domain # Edit with secure passwords and your domain
# 2. Deploy # 2. Deploy
podman-compose --env-file compose/.env.production -f compose/production.yml up -d podman compose --env-file compose/.env.production -f compose/production.yml up -d
``` ```
## Common Tasks ## Common Tasks

View file

@ -1,6 +1,6 @@
{ {
"db_name": "PostgreSQL", "db_name": "PostgreSQL",
"query": "SELECT id, setup_completed, instance_name, platform_mode,\n registration_enabled, registration_mode,\n default_community_visibility, allow_private_communities,\n default_plugin_policy, default_moderation_mode\n FROM instance_settings LIMIT 1", "query": "SELECT id, setup_completed, instance_name, platform_mode,\n theme_id, registration_enabled, registration_mode,\n default_community_visibility, allow_private_communities,\n default_plugin_policy, default_moderation_mode\n FROM instance_settings LIMIT 1",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@ -25,31 +25,36 @@
}, },
{ {
"ordinal": 4, "ordinal": 4,
"name": "theme_id",
"type_info": "Varchar"
},
{
"ordinal": 5,
"name": "registration_enabled", "name": "registration_enabled",
"type_info": "Bool" "type_info": "Bool"
}, },
{ {
"ordinal": 5, "ordinal": 6,
"name": "registration_mode", "name": "registration_mode",
"type_info": "Varchar" "type_info": "Varchar"
}, },
{ {
"ordinal": 6, "ordinal": 7,
"name": "default_community_visibility", "name": "default_community_visibility",
"type_info": "Varchar" "type_info": "Varchar"
}, },
{ {
"ordinal": 7, "ordinal": 8,
"name": "allow_private_communities", "name": "allow_private_communities",
"type_info": "Bool" "type_info": "Bool"
}, },
{ {
"ordinal": 8, "ordinal": 9,
"name": "default_plugin_policy", "name": "default_plugin_policy",
"type_info": "Varchar" "type_info": "Varchar"
}, },
{ {
"ordinal": 9, "ordinal": 10,
"name": "default_moderation_mode", "name": "default_moderation_mode",
"type_info": "Varchar" "type_info": "Varchar"
} }
@ -67,8 +72,9 @@
false, false,
false, false,
false, false,
false,
false false
] ]
}, },
"hash": "18c0fb05da45a3eea514f660bc4ac4d6aca71442645666a9c08db8f2a564ff6c" "hash": "0620f314de8df0c7990ef63fda55f2ff646d5159c59d8288e4ffdfdb07dc159f"
} }

View file

@ -1,6 +1,6 @@
{ {
"db_name": "PostgreSQL", "db_name": "PostgreSQL",
"query": "SELECT setup_completed, instance_name, platform_mode,\n registration_enabled, registration_mode,\n single_community_id\n FROM instance_settings\n LIMIT 1", "query": "SELECT setup_completed, instance_name, theme_id, platform_mode,\n registration_enabled, registration_mode,\n single_community_id\n FROM instance_settings\n LIMIT 1",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@ -15,21 +15,26 @@
}, },
{ {
"ordinal": 2, "ordinal": 2,
"name": "platform_mode", "name": "theme_id",
"type_info": "Varchar" "type_info": "Varchar"
}, },
{ {
"ordinal": 3, "ordinal": 3,
"name": "platform_mode",
"type_info": "Varchar"
},
{
"ordinal": 4,
"name": "registration_enabled", "name": "registration_enabled",
"type_info": "Bool" "type_info": "Bool"
}, },
{ {
"ordinal": 4, "ordinal": 5,
"name": "registration_mode", "name": "registration_mode",
"type_info": "Varchar" "type_info": "Varchar"
}, },
{ {
"ordinal": 5, "ordinal": 6,
"name": "single_community_id", "name": "single_community_id",
"type_info": "Uuid" "type_info": "Uuid"
} }
@ -43,8 +48,9 @@
false, false,
false, false,
false, false,
false,
true true
] ]
}, },
"hash": "200e864fa5778cf58d36d49f94a4006f7d104eb84e6f166b795df0f222ee93d8" "hash": "593dc329afc129680dd505221df649aa8cb544841fa78a5fe740adfbb4439502"
} }

View file

@ -1,6 +1,6 @@
{ {
"db_name": "PostgreSQL", "db_name": "PostgreSQL",
"query": "UPDATE instance_settings SET\n setup_completed = true,\n setup_completed_at = NOW(),\n setup_completed_by = $1,\n instance_name = $2,\n platform_mode = $3,\n single_community_id = $4\n RETURNING id, setup_completed, instance_name, platform_mode,\n registration_enabled, registration_mode,\n default_community_visibility, allow_private_communities,\n default_plugin_policy, default_moderation_mode", "query": "UPDATE instance_settings SET\n setup_completed = true,\n setup_completed_at = NOW(),\n setup_completed_by = $1,\n instance_name = $2,\n platform_mode = $3,\n single_community_id = $4\n RETURNING id, setup_completed, instance_name, platform_mode,\n theme_id,\n registration_enabled, registration_mode,\n default_community_visibility, allow_private_communities,\n default_plugin_policy, default_moderation_mode",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@ -25,31 +25,36 @@
}, },
{ {
"ordinal": 4, "ordinal": 4,
"name": "theme_id",
"type_info": "Varchar"
},
{
"ordinal": 5,
"name": "registration_enabled", "name": "registration_enabled",
"type_info": "Bool" "type_info": "Bool"
}, },
{ {
"ordinal": 5, "ordinal": 6,
"name": "registration_mode", "name": "registration_mode",
"type_info": "Varchar" "type_info": "Varchar"
}, },
{ {
"ordinal": 6, "ordinal": 7,
"name": "default_community_visibility", "name": "default_community_visibility",
"type_info": "Varchar" "type_info": "Varchar"
}, },
{ {
"ordinal": 7, "ordinal": 8,
"name": "allow_private_communities", "name": "allow_private_communities",
"type_info": "Bool" "type_info": "Bool"
}, },
{ {
"ordinal": 8, "ordinal": 9,
"name": "default_plugin_policy", "name": "default_plugin_policy",
"type_info": "Varchar" "type_info": "Varchar"
}, },
{ {
"ordinal": 9, "ordinal": 10,
"name": "default_moderation_mode", "name": "default_moderation_mode",
"type_info": "Varchar" "type_info": "Varchar"
} }
@ -72,8 +77,9 @@
false, false,
false, false,
false, false,
false,
false false
] ]
}, },
"hash": "b9586185e84644f0bd936d7bf5e9bec6ebeaba77ab354d0b7096d9334656497f" "hash": "8dd178663df95d64d72c776e3b8bda63851d2ad0e13a6d80b327610078ecbaeb"
} }

View file

@ -1,6 +1,6 @@
{ {
"db_name": "PostgreSQL", "db_name": "PostgreSQL",
"query": "UPDATE instance_settings SET\n instance_name = COALESCE($1, instance_name),\n platform_mode = COALESCE($2, platform_mode),\n registration_enabled = COALESCE($3, registration_enabled),\n registration_mode = COALESCE($4, registration_mode)\n RETURNING id, setup_completed, instance_name, platform_mode,\n registration_enabled, registration_mode,\n default_community_visibility, allow_private_communities,\n default_plugin_policy, default_moderation_mode", "query": "UPDATE instance_settings SET\n instance_name = COALESCE($1, instance_name),\n theme_id = COALESCE($2, theme_id),\n platform_mode = COALESCE($3, platform_mode),\n registration_enabled = COALESCE($4, registration_enabled),\n registration_mode = COALESCE($5, registration_mode)\n RETURNING id, setup_completed, instance_name, platform_mode,\n theme_id, registration_enabled, registration_mode,\n default_community_visibility, allow_private_communities,\n default_plugin_policy, default_moderation_mode",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@ -25,37 +25,43 @@
}, },
{ {
"ordinal": 4, "ordinal": 4,
"name": "theme_id",
"type_info": "Varchar"
},
{
"ordinal": 5,
"name": "registration_enabled", "name": "registration_enabled",
"type_info": "Bool" "type_info": "Bool"
}, },
{ {
"ordinal": 5, "ordinal": 6,
"name": "registration_mode", "name": "registration_mode",
"type_info": "Varchar" "type_info": "Varchar"
}, },
{ {
"ordinal": 6, "ordinal": 7,
"name": "default_community_visibility", "name": "default_community_visibility",
"type_info": "Varchar" "type_info": "Varchar"
}, },
{ {
"ordinal": 7, "ordinal": 8,
"name": "allow_private_communities", "name": "allow_private_communities",
"type_info": "Bool" "type_info": "Bool"
}, },
{ {
"ordinal": 8, "ordinal": 9,
"name": "default_plugin_policy", "name": "default_plugin_policy",
"type_info": "Varchar" "type_info": "Varchar"
}, },
{ {
"ordinal": 9, "ordinal": 10,
"name": "default_moderation_mode", "name": "default_moderation_mode",
"type_info": "Varchar" "type_info": "Varchar"
} }
], ],
"parameters": { "parameters": {
"Left": [ "Left": [
"Varchar",
"Varchar", "Varchar",
"Varchar", "Varchar",
"Bool", "Bool",
@ -72,8 +78,9 @@
false, false,
false, false,
false, false,
false,
false false
] ]
}, },
"hash": "c35608b0d7569f739dda24b3da59b7b500ff26f5e79433b3f7e3625d91177d26" "hash": "a903d88370faa52169ffd4ec6a54a789ee4a6173fe84aca0ef8dedaa46b1f93c"
} }

View file

@ -0,0 +1,2 @@
ALTER TABLE instance_settings
ADD COLUMN IF NOT EXISTS theme_id VARCHAR(50) NOT NULL DEFAULT 'neutral';

View file

@ -0,0 +1,3 @@
UPDATE instance_settings
SET theme_id = 'breeze-dark'
WHERE theme_id = 'neutral';

View file

@ -40,7 +40,7 @@ async fn get_demo_status(State(state): State<DemoState>) -> impl IntoResponse {
vec![ vec![
"Cannot delete communities", "Cannot delete communities",
"Cannot delete users", "Cannot delete users",
"Cannot modify instance settings", "Cannot modify instance settings (except instance theme)",
"Data resets periodically" "Data resets periodically"
] ]
} else { } else {

View file

@ -30,6 +30,7 @@ pub struct SetupStatus {
pub struct PublicInstanceSettings { pub struct PublicInstanceSettings {
pub setup_completed: bool, pub setup_completed: bool,
pub instance_name: String, pub instance_name: String,
pub theme_id: String,
pub platform_mode: String, pub platform_mode: String,
pub registration_enabled: bool, pub registration_enabled: bool,
pub registration_mode: String, pub registration_mode: String,
@ -42,6 +43,7 @@ pub struct InstanceSettings {
pub id: Uuid, pub id: Uuid,
pub setup_completed: bool, pub setup_completed: bool,
pub instance_name: String, pub instance_name: String,
pub theme_id: String,
pub platform_mode: String, pub platform_mode: String,
pub registration_enabled: bool, pub registration_enabled: bool,
pub registration_mode: String, pub registration_mode: String,
@ -64,6 +66,8 @@ pub struct UpdateInstanceRequest {
#[serde(default)] #[serde(default)]
pub instance_name: Option<String>, pub instance_name: Option<String>,
#[serde(default)] #[serde(default)]
pub theme_id: Option<String>,
#[serde(default)]
pub platform_mode: Option<String>, pub platform_mode: Option<String>,
#[serde(default)] #[serde(default)]
pub registration_enabled: Option<bool>, pub registration_enabled: Option<bool>,
@ -71,6 +75,32 @@ pub struct UpdateInstanceRequest {
pub registration_mode: Option<String>, pub registration_mode: Option<String>,
} }
const KNOWN_THEME_IDS: [&str; 4] = ["neutral", "breeze-light", "breeze-dark", "opensuse"];
fn validate_theme_id(theme_id: &str) -> Result<(), (StatusCode, String)> {
if theme_id.trim().is_empty() {
return Err((StatusCode::BAD_REQUEST, "Theme cannot be empty".to_string()));
}
if theme_id.len() > 50 {
return Err((StatusCode::BAD_REQUEST, "Theme is too long".to_string()));
}
if !theme_id
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
{
return Err((
StatusCode::BAD_REQUEST,
"Theme contains invalid characters".to_string(),
));
}
if !KNOWN_THEME_IDS.iter().any(|t| t == &theme_id) {
return Err((StatusCode::BAD_REQUEST, "Unknown theme".to_string()));
}
Ok(())
}
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
pub struct CommunitySettings { pub struct CommunitySettings {
pub community_id: Uuid, pub community_id: Uuid,
@ -121,7 +151,7 @@ async fn get_public_settings(
State(pool): State<PgPool>, State(pool): State<PgPool>,
) -> Result<Json<PublicInstanceSettings>, String> { ) -> Result<Json<PublicInstanceSettings>, String> {
let row = sqlx::query!( let row = sqlx::query!(
r#"SELECT setup_completed, instance_name, platform_mode, r#"SELECT setup_completed, instance_name, theme_id, platform_mode,
registration_enabled, registration_mode, registration_enabled, registration_mode,
single_community_id single_community_id
FROM instance_settings FROM instance_settings
@ -135,6 +165,7 @@ async fn get_public_settings(
return Ok(Json(PublicInstanceSettings { return Ok(Json(PublicInstanceSettings {
setup_completed: false, setup_completed: false,
instance_name: "Likwid".to_string(), instance_name: "Likwid".to_string(),
theme_id: "neutral".to_string(),
platform_mode: "open".to_string(), platform_mode: "open".to_string(),
registration_enabled: true, registration_enabled: true,
registration_mode: "open".to_string(), registration_mode: "open".to_string(),
@ -161,6 +192,7 @@ async fn get_public_settings(
Ok(Json(PublicInstanceSettings { Ok(Json(PublicInstanceSettings {
setup_completed: r.setup_completed, setup_completed: r.setup_completed,
instance_name: r.instance_name, instance_name: r.instance_name,
theme_id: r.theme_id,
platform_mode: r.platform_mode, platform_mode: r.platform_mode,
registration_enabled: r.registration_enabled, registration_enabled: r.registration_enabled,
registration_mode: r.registration_mode, registration_mode: r.registration_mode,
@ -224,6 +256,7 @@ async fn complete_setup(
platform_mode = $3, platform_mode = $3,
single_community_id = $4 single_community_id = $4
RETURNING id, setup_completed, instance_name, platform_mode, RETURNING id, setup_completed, instance_name, platform_mode,
theme_id,
registration_enabled, registration_mode, registration_enabled, registration_mode,
default_community_visibility, allow_private_communities, default_community_visibility, allow_private_communities,
default_plugin_policy, default_moderation_mode"#, default_plugin_policy, default_moderation_mode"#,
@ -240,6 +273,7 @@ async fn complete_setup(
id: settings.id, id: settings.id,
setup_completed: settings.setup_completed, setup_completed: settings.setup_completed,
instance_name: settings.instance_name, instance_name: settings.instance_name,
theme_id: settings.theme_id,
platform_mode: settings.platform_mode, platform_mode: settings.platform_mode,
registration_enabled: settings.registration_enabled, registration_enabled: settings.registration_enabled,
registration_mode: settings.registration_mode, registration_mode: settings.registration_mode,
@ -260,7 +294,7 @@ async fn get_instance_settings(
let s = sqlx::query!( let s = sqlx::query!(
r#"SELECT id, setup_completed, instance_name, platform_mode, r#"SELECT id, setup_completed, instance_name, platform_mode,
registration_enabled, registration_mode, theme_id, registration_enabled, registration_mode,
default_community_visibility, allow_private_communities, default_community_visibility, allow_private_communities,
default_plugin_policy, default_moderation_mode default_plugin_policy, default_moderation_mode
FROM instance_settings LIMIT 1"# FROM instance_settings LIMIT 1"#
@ -273,6 +307,7 @@ async fn get_instance_settings(
id: s.id, id: s.id,
setup_completed: s.setup_completed, setup_completed: s.setup_completed,
instance_name: s.instance_name, instance_name: s.instance_name,
theme_id: s.theme_id,
platform_mode: s.platform_mode, platform_mode: s.platform_mode,
registration_enabled: s.registration_enabled, registration_enabled: s.registration_enabled,
registration_mode: s.registration_mode, registration_mode: s.registration_mode,
@ -293,24 +328,37 @@ async fn update_instance_settings(
// Check platform settings permission // Check platform settings permission
require_permission(&pool, auth.user_id, perms::PLATFORM_SETTINGS, None).await?; require_permission(&pool, auth.user_id, perms::PLATFORM_SETTINGS, None).await?;
if let Some(theme_id) = req.theme_id.as_deref() {
validate_theme_id(theme_id)?;
}
if config.is_demo() { if config.is_demo() {
return Err(( let allowed = req.theme_id.is_some()
StatusCode::FORBIDDEN, && req.instance_name.is_none()
"Instance settings cannot be modified in demo mode".to_string(), && req.platform_mode.is_none()
)); && req.registration_enabled.is_none()
&& req.registration_mode.is_none();
if !allowed {
return Err((
StatusCode::FORBIDDEN,
"Only theme updates are allowed in demo mode".to_string(),
));
}
} }
let s = sqlx::query!( let s = sqlx::query!(
r#"UPDATE instance_settings SET r#"UPDATE instance_settings SET
instance_name = COALESCE($1, instance_name), instance_name = COALESCE($1, instance_name),
platform_mode = COALESCE($2, platform_mode), theme_id = COALESCE($2, theme_id),
registration_enabled = COALESCE($3, registration_enabled), platform_mode = COALESCE($3, platform_mode),
registration_mode = COALESCE($4, registration_mode) registration_enabled = COALESCE($4, registration_enabled),
registration_mode = COALESCE($5, registration_mode)
RETURNING id, setup_completed, instance_name, platform_mode, RETURNING id, setup_completed, instance_name, platform_mode,
registration_enabled, registration_mode, theme_id, registration_enabled, registration_mode,
default_community_visibility, allow_private_communities, default_community_visibility, allow_private_communities,
default_plugin_policy, default_moderation_mode"#, default_plugin_policy, default_moderation_mode"#,
req.instance_name, req.instance_name,
req.theme_id,
req.platform_mode, req.platform_mode,
req.registration_enabled, req.registration_enabled,
req.registration_mode req.registration_mode
@ -323,6 +371,7 @@ async fn update_instance_settings(
id: s.id, id: s.id,
setup_completed: s.setup_completed, setup_completed: s.setup_completed,
instance_name: s.instance_name, instance_name: s.instance_name,
theme_id: s.theme_id,
platform_mode: s.platform_mode, platform_mode: s.platform_mode,
registration_enabled: s.registration_enabled, registration_enabled: s.registration_enabled,
registration_mode: s.registration_mode, registration_mode: s.registration_mode,

View file

@ -1,6 +1,6 @@
# Demo deployment - includes demo users, seed data, and restricted actions # Demo deployment - includes demo users, seed data, and restricted actions
# Usage: podman-compose --env-file compose/.env.demo -f compose/demo.yml up -d # Usage: podman compose --env-file compose/.env.demo -f compose/demo.yml up -d
# Reset: podman-compose --env-file compose/.env.demo -f compose/demo.yml down -v; podman-compose --env-file compose/.env.demo -f compose/demo.yml up -d # Reset: podman compose --env-file compose/.env.demo -f compose/demo.yml down -v; podman compose --env-file compose/.env.demo -f compose/demo.yml up -d
services: services:
postgres: postgres:

View file

@ -1,61 +1,61 @@
# Production deployment - clean instance without demo data # Production deployment - clean instance without demo data
# Usage: podman-compose -f compose/production.yml up -d # Usage: podman compose -f compose/production.yml up -d
services: services:
postgres: postgres:
image: postgres:16 image: postgres:16
container_name: likwid-prod-db container_name: likwid-prod-db
restart: unless-stopped restart: unless-stopped
ports: ports:
- "${DB_PORT:-5432}:5432" - "${DB_PORT:-5432}:5432"
environment: environment:
POSTGRES_USER: ${POSTGRES_USER:-likwid} POSTGRES_USER: ${POSTGRES_USER:-likwid}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB:-likwid_prod} POSTGRES_DB: ${POSTGRES_DB:-likwid_prod}
volumes: volumes:
- likwid_prod_data:/var/lib/postgresql/data - likwid_prod_data:/var/lib/postgresql/data
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-likwid}"] test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-likwid}"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
backend: backend:
build: build:
context: ../backend context: ../backend
dockerfile: Dockerfile dockerfile: Dockerfile
args: args:
INCLUDE_DEMO_SEED: "false" INCLUDE_DEMO_SEED: "false"
container_name: likwid-prod-backend container_name: likwid-prod-backend
restart: unless-stopped restart: unless-stopped
ports: ports:
- "${BACKEND_PORT:-3000}:3000" - "${BACKEND_PORT:-3000}:3000"
environment: environment:
DATABASE_URL: postgres://${POSTGRES_USER:-likwid}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-likwid_prod} DATABASE_URL: postgres://${POSTGRES_USER:-likwid}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-likwid_prod}
JWT_SECRET: ${JWT_SECRET} JWT_SECRET: ${JWT_SECRET}
SERVER_HOST: 0.0.0.0 SERVER_HOST: 0.0.0.0
SERVER_PORT: 3000 SERVER_PORT: 3000
DEMO_MODE: "false" DEMO_MODE: "false"
RUST_LOG: info RUST_LOG: info
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
frontend: frontend:
build: build:
context: ../frontend context: ../frontend
dockerfile: Dockerfile dockerfile: Dockerfile
args: args:
API_BASE: ${API_BASE:-http://localhost:3000} API_BASE: ${API_BASE:-http://localhost:3000}
container_name: likwid-prod-frontend container_name: likwid-prod-frontend
restart: unless-stopped restart: unless-stopped
ports: ports:
- "${FRONTEND_PORT:-4321}:4321" - "${FRONTEND_PORT:-4321}:4321"
environment: environment:
INTERNAL_API_BASE: http://backend:3000 INTERNAL_API_BASE: http://backend:3000
API_BASE: ${API_BASE:-http://localhost:3000} API_BASE: ${API_BASE:-http://localhost:3000}
depends_on: depends_on:
- backend - backend
volumes: volumes:
likwid_prod_data: likwid_prod_data:

View file

@ -49,7 +49,7 @@ find "$BACKUP_DIR" -name "*.dump.gz" -mtime +$RETENTION_DAYS -delete
### Containerized Backup ### Containerized Backup
```bash ```bash
# If using podman-compose # If using podman compose
podman exec likwid-prod-db pg_dump -U likwid likwid_prod > backup.sql podman exec likwid-prod-db pg_dump -U likwid likwid_prod > backup.sql
``` ```

View file

@ -37,7 +37,7 @@ Managed via the Admin panel or API:
- **Instance Name** - Display name for your Likwid instance - **Instance Name** - Display name for your Likwid instance
- **Instance Description** - Brief description - **Instance Description** - Brief description
- **Registration** - Open, invite-only, or closed - **Registration** - Open, invite-only, or closed
- **Email Verification** - Required or optional - **Approval workflows** - Registration and community creation can be open, invite-only, or require admin approval
### Features ### Features

View file

@ -34,7 +34,7 @@ Required settings:
```bash ```bash
cd compose cd compose
podman-compose --env-file .env.production -f production.yml up -d podman compose --env-file .env.production -f production.yml up -d
``` ```
### 4. Access ### 4. Access

View file

@ -11,7 +11,7 @@ Likwid is a modular governance platform for distributed organizations. This guid
- Email address - Email address
- Display name (shown to others) - Display name (shown to others)
- Password - Password
4. Verify your email if required by the instance 4. If registration requires approval, your account will be pending until an admin approves it
## Exploring Without an Account ## Exploring Without an Account

View file

@ -4,7 +4,7 @@ interface Props {
} }
import { DEFAULT_THEME, themes as themeRegistry } from '../lib/themes'; import { DEFAULT_THEME, themes as themeRegistry } from '../lib/themes';
import { API_BASE as apiBase } from '../lib/api'; import { API_BASE as apiBase, SERVER_API_BASE } from '../lib/api';
import VotingIcons from '../components/icons/VotingIcons.astro'; import VotingIcons from '../components/icons/VotingIcons.astro';
import DesignSystemStyles from '../components/ui/DesignSystemStyles.astro'; import DesignSystemStyles from '../components/ui/DesignSystemStyles.astro';
@ -22,11 +22,24 @@ const publicDemoSite = isEnabled((globalThis as any).process?.env?.PUBLIC_DEMO_S
Object.entries(themeRegistry).map(([id, t]) => [id, { isDark: t.isDark, colors: t.colors }]), Object.entries(themeRegistry).map(([id, t]) => [id, { isDark: t.isDark, colors: t.colors }]),
); );
const defaultTheme = DEFAULT_THEME; const settingsApiBase = SERVER_API_BASE || 'http://127.0.0.1:3000';
let defaultTheme = DEFAULT_THEME;
try {
const res = await fetch(`${settingsApiBase}/api/settings/public`);
if (res.ok) {
const settings = await res.json();
if (settings && typeof settings.theme_id === 'string' && themeRegistry[settings.theme_id]) {
defaultTheme = settings.theme_id;
}
}
} catch (_e) {}
const initialTheme = themeRegistry[defaultTheme] || themeRegistry[DEFAULT_THEME];
--- ---
<!doctype html> <!doctype html>
<html lang="en" data-theme="neutral" data-theme-mode="dark"> <html lang="en" data-theme={defaultTheme} data-theme-mode={initialTheme.isDark ? 'dark' : 'light'}>
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
@ -36,9 +49,9 @@ const publicDemoSite = isEnabled((globalThis as any).process?.env?.PUBLIC_DEMO_S
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<title>{title} | Likwid</title> <title>{title} | Likwid</title>
<script is:inline define:vars={{ themes, defaultTheme }}> <script is:inline define:vars={{ themes, defaultTheme, publicDemoSite }}>
(function() { (function() {
const saved = localStorage.getItem('likwid-theme') || defaultTheme; const saved = publicDemoSite ? defaultTheme : (localStorage.getItem('likwid-theme') || defaultTheme);
const theme = themes[saved] || themes[defaultTheme]; const theme = themes[saved] || themes[defaultTheme];
const root = document.documentElement; const root = document.documentElement;
const c = theme.colors; const c = theme.colors;

View file

@ -5,6 +5,7 @@ interface Props {
} }
import { DEFAULT_THEME, themes as themeRegistry } from '../lib/themes'; import { DEFAULT_THEME, themes as themeRegistry } from '../lib/themes';
import { SERVER_API_BASE } from '../lib/api';
import DesignSystemStyles from '../components/ui/DesignSystemStyles.astro'; import DesignSystemStyles from '../components/ui/DesignSystemStyles.astro';
function isEnabled(v: string | undefined): boolean { function isEnabled(v: string | undefined): boolean {
@ -21,11 +22,24 @@ const themes = Object.fromEntries(
Object.entries(themeRegistry).map(([id, t]) => [id, { isDark: t.isDark, colors: t.colors }]), Object.entries(themeRegistry).map(([id, t]) => [id, { isDark: t.isDark, colors: t.colors }]),
); );
const defaultTheme = DEFAULT_THEME; const settingsApiBase = SERVER_API_BASE || 'http://127.0.0.1:3000';
let defaultTheme = DEFAULT_THEME;
try {
const res = await fetch(`${settingsApiBase}/api/settings/public`);
if (res.ok) {
const settings = await res.json();
if (settings && typeof settings.theme_id === 'string' && themeRegistry[settings.theme_id]) {
defaultTheme = settings.theme_id;
}
}
} catch (_e) {}
const initialTheme = themeRegistry[defaultTheme] || themeRegistry[DEFAULT_THEME];
--- ---
<!doctype html> <!doctype html>
<html lang="en" data-theme="neutral" data-theme-mode="dark"> <html lang="en" data-theme={defaultTheme} data-theme-mode={initialTheme.isDark ? 'dark' : 'light'}>
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
@ -35,9 +49,9 @@ const defaultTheme = DEFAULT_THEME;
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<title>{title} | Likwid</title> <title>{title} | Likwid</title>
<script is:inline define:vars={{ themes, defaultTheme }}> <script is:inline define:vars={{ themes, defaultTheme, publicDemoSite }}>
(function() { (function() {
const saved = localStorage.getItem('likwid-theme') || defaultTheme; const saved = publicDemoSite ? defaultTheme : (localStorage.getItem('likwid-theme') || defaultTheme);
const theme = themes[saved] || themes[defaultTheme]; const theme = themes[saved] || themes[defaultTheme];
const root = document.documentElement; const root = document.documentElement;
const c = theme.colors; const c = theme.colors;
@ -103,11 +117,6 @@ const defaultTheme = DEFAULT_THEME;
<a href="/docs">Documentation</a> <a href="/docs">Documentation</a>
</div> </div>
<div class="nav-actions"> <div class="nav-actions">
<select id="theme-select" class="theme-select" aria-label="Theme">
{Object.values(themeRegistry).map((t) => (
<option value={t.id}>{t.name}</option>
))}
</select>
<a href="/demo" class="ui-btn ui-btn-primary">Explore Demo</a> <a href="/demo" class="ui-btn ui-btn-primary">Explore Demo</a>
{!publicDemoSite ? <a href="/login" class="ui-btn ui-btn-secondary">Sign In</a> : null} {!publicDemoSite ? <a href="/login" class="ui-btn ui-btn-secondary">Sign In</a> : null}
</div> </div>
@ -154,10 +163,9 @@ const defaultTheme = DEFAULT_THEME;
</div> </div>
</footer> </footer>
</div> </div>
<script is:inline define:vars={{ themes, defaultTheme }}> <script is:inline define:vars={{ themes, defaultTheme, publicDemoSite }}>
const nav = document.getElementById('public-nav'); const nav = document.getElementById('public-nav');
const toggle = document.getElementById('public-nav-toggle'); const toggle = document.getElementById('public-nav-toggle');
const themeSelect = document.getElementById('theme-select');
function openNav() { function openNav() {
if (!nav || !toggle) return; if (!nav || !toggle) return;
@ -239,58 +247,6 @@ const defaultTheme = DEFAULT_THEME;
} }
}); });
} }
function applySelectedTheme() {
if (!(themeSelect instanceof HTMLSelectElement)) return;
const selected = themeSelect.value || defaultTheme;
const theme = themes[selected] || themes[defaultTheme];
const root = document.documentElement;
const c = theme.colors;
root.style.setProperty('--color-bg', c.bg);
root.style.setProperty('--color-bg-alt', c.bgAlt);
root.style.setProperty('--color-surface', c.surface);
root.style.setProperty('--color-surface-hover', c.surfaceHover);
root.style.setProperty('--color-border', c.border);
root.style.setProperty('--color-border-hover', c.borderHover);
root.style.setProperty('--color-text', c.text);
root.style.setProperty('--color-text-muted', c.textMuted);
root.style.setProperty('--color-text-inverse', c.textInverse);
root.style.setProperty('--color-primary', c.primary);
root.style.setProperty('--color-primary-hover', c.primaryHover);
root.style.setProperty('--color-primary-muted', c.primaryMuted);
root.style.setProperty('--color-secondary', c.secondary);
root.style.setProperty('--color-secondary-hover', c.secondaryHover);
root.style.setProperty('--color-info', c.info);
root.style.setProperty('--color-info-hover', c.infoHover);
root.style.setProperty('--color-info-muted', c.infoMuted);
root.style.setProperty('--color-neutral', c.neutral);
root.style.setProperty('--color-neutral-hover', c.neutralHover);
root.style.setProperty('--color-neutral-muted', c.neutralMuted);
root.style.setProperty('--color-success', c.success);
root.style.setProperty('--color-success-muted', c.successMuted);
root.style.setProperty('--color-success-hover', c.successHover);
root.style.setProperty('--color-warning', c.warning);
root.style.setProperty('--color-warning-muted', c.warningMuted);
root.style.setProperty('--color-error', c.error);
root.style.setProperty('--color-error-muted', c.errorMuted);
root.style.setProperty('--color-error-hover', c.errorHover);
root.style.setProperty('--color-link', c.link);
root.style.setProperty('--color-link-visited', c.linkVisited);
root.style.setProperty('--color-overlay', c.overlay);
root.style.setProperty('--color-field-bg', c.fieldBg);
root.style.setProperty('--color-on-primary', c.onPrimary);
root.setAttribute('data-theme', selected);
root.setAttribute('data-theme-mode', theme.isDark ? 'dark' : 'light');
}
if (themeSelect instanceof HTMLSelectElement) {
const saved = localStorage.getItem('likwid-theme') || defaultTheme;
themeSelect.value = themes[saved] ? saved : defaultTheme;
themeSelect.addEventListener('change', () => {
localStorage.setItem('likwid-theme', themeSelect.value);
applySelectedTheme();
});
}
</script> </script>
</body> </body>
</html> </html>
@ -449,20 +405,6 @@ const defaultTheme = DEFAULT_THEME;
align-items: center; align-items: center;
} }
.theme-select {
width: auto;
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
background: rgba(255, 255, 255, 0.06);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-text);
}
.theme-select:hover {
border-color: var(--color-border-hover);
}
.public-main { .public-main {
flex: 1; flex: 1;
} }

View file

@ -29,6 +29,16 @@ import { API_BASE as apiBase } from '../../lib/api';
<label for="instance_name">Platform Name</label> <label for="instance_name">Platform Name</label>
<input type="text" id="instance_name" name="instance_name" /> <input type="text" id="instance_name" name="instance_name" />
</div> </div>
<div class="form-group">
<label for="theme_id">Theme</label>
<select id="theme_id" name="theme_id">
<option value="neutral">Neutral Dark</option>
<option value="breeze-light">Breeze Light</option>
<option value="breeze-dark">Breeze Dark</option>
<option value="opensuse">openSUSE</option>
</select>
</div>
</section> </section>
<!-- Platform Mode --> <!-- Platform Mode -->
@ -182,6 +192,8 @@ import { API_BASE as apiBase } from '../../lib/api';
const saveBtn = document.getElementById('save-btn'); const saveBtn = document.getElementById('save-btn');
const saveStatus = document.getElementById('save-status'); const saveStatus = document.getElementById('save-status');
let initialSettings = null;
if (!form || !loadingEl || !errorEl || !saveStatus) return; if (!form || !loadingEl || !errorEl || !saveStatus) return;
async function loadSettings() { async function loadSettings() {
@ -213,9 +225,11 @@ import { API_BASE as apiBase } from '../../lib/api';
} }
const settings = await res.json(); const settings = await res.json();
initialSettings = settings;
// Populate form // Populate form
(document.getElementById('instance_name')).value = settings.instance_name; (document.getElementById('instance_name')).value = settings.instance_name;
(document.getElementById('theme_id')).value = settings.theme_id || 'neutral';
(document.getElementById('platform_mode')).value = settings.platform_mode; (document.getElementById('platform_mode')).value = settings.platform_mode;
(document.getElementById('registration_enabled')).checked = settings.registration_enabled; (document.getElementById('registration_enabled')).checked = settings.registration_enabled;
(document.getElementById('registration_mode')).value = settings.registration_mode; (document.getElementById('registration_mode')).value = settings.registration_mode;
@ -240,14 +254,39 @@ import { API_BASE as apiBase } from '../../lib/api';
if (saveBtn) saveBtn.disabled = true; if (saveBtn) saveBtn.disabled = true;
saveStatus.textContent = 'Saving...'; saveStatus.textContent = 'Saving...';
saveStatus.style.color = '';
const data = { const current = {
instance_name: (document.getElementById('instance_name')).value, instance_name: (document.getElementById('instance_name')).value,
theme_id: (document.getElementById('theme_id')).value,
platform_mode: (document.getElementById('platform_mode')).value, platform_mode: (document.getElementById('platform_mode')).value,
registration_enabled: (document.getElementById('registration_enabled')).checked, registration_enabled: (document.getElementById('registration_enabled')).checked,
registration_mode: (document.getElementById('registration_mode')).value registration_mode: (document.getElementById('registration_mode')).value,
}; };
const data = {};
if (!initialSettings || current.instance_name !== initialSettings.instance_name) {
data.instance_name = current.instance_name;
}
if (!initialSettings || current.theme_id !== initialSettings.theme_id) {
data.theme_id = current.theme_id;
}
if (!initialSettings || current.platform_mode !== initialSettings.platform_mode) {
data.platform_mode = current.platform_mode;
}
if (!initialSettings || current.registration_enabled !== initialSettings.registration_enabled) {
data.registration_enabled = current.registration_enabled;
}
if (!initialSettings || current.registration_mode !== initialSettings.registration_mode) {
data.registration_mode = current.registration_mode;
}
if (Object.keys(data).length === 0) {
saveStatus.textContent = 'No changes to save.';
if (saveBtn) saveBtn.disabled = false;
return;
}
try { try {
const res = await fetch(`${API_BASE}/api/settings/instance`, { const res = await fetch(`${API_BASE}/api/settings/instance`, {
method: 'PATCH', method: 'PATCH',
@ -262,8 +301,17 @@ import { API_BASE as apiBase } from '../../lib/api';
throw new Error(await res.text()); throw new Error(await res.text());
} }
const updated = await res.json();
initialSettings = updated;
saveStatus.textContent = 'Saved!'; saveStatus.textContent = 'Saved!';
setTimeout(() => { saveStatus.textContent = ''; }, 3000); setTimeout(() => { saveStatus.textContent = ''; }, 3000);
if (data.theme_id) {
setTimeout(() => {
window.location.reload();
}, 150);
}
} catch (err) { } catch (err) {
saveStatus.textContent = 'Error: ' + err.message; saveStatus.textContent = 'Error: ' + err.message;
saveStatus.style.color = '#c62828'; saveStatus.style.color = '#c62828';

View file

@ -1,6 +1,14 @@
--- ---
import Layout from '../layouts/Layout.astro'; import Layout from '../layouts/Layout.astro';
import { API_BASE as apiBase } from '../lib/api'; import { API_BASE as apiBase } from '../lib/api';
function isEnabled(v: string | undefined): boolean {
if (!v) return false;
const n = v.trim().toLowerCase();
return n === '1' || n === 'true' || n === 'yes' || n === 'on';
}
const publicDemoSite = isEnabled((globalThis as any).process?.env?.PUBLIC_DEMO_SITE);
--- ---
<Layout title="Settings"> <Layout title="Settings">
@ -13,7 +21,7 @@ import { API_BASE as apiBase } from '../lib/api';
</section> </section>
</Layout> </Layout>
<script define:vars={{ apiBase }}> <script define:vars={{ apiBase, publicDemoSite }}>
import { getAllThemes, loadSavedTheme, saveTheme } from '../lib/themes'; import { getAllThemes, loadSavedTheme, saveTheme } from '../lib/themes';
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
@ -42,36 +50,50 @@ import { API_BASE as apiBase } from '../lib/api';
const user = await res.json(); const user = await res.json();
const currentTheme = loadSavedTheme(); let appearanceHtml = '';
const themeOptions = getAllThemes() if (publicDemoSite) {
.map((t) => { appearanceHtml = `
const selected = t.id === currentTheme ? 'selected' : ''; <div class="form-section ui-card ui-card-pad-lg ui-form">
return `<option value="${t.id}" ${selected}>${t.name}</option>`; <h2>Appearance</h2>
}) <p class="hint">On the public demo, appearance is managed by the instance administrator.</p>
.join('');
container.innerHTML = `
<div class="form-section ui-card ui-card-pad-lg ui-form">
<h2>Appearance</h2>
<div class="form-group">
<label for="theme-select">Theme</label>
<select id="theme-select" class="theme-select">
${themeOptions}
</select>
<p class="hint">Choose a visual theme for the interface</p>
</div> </div>
`;
} else {
const currentTheme = loadSavedTheme();
const themeOptions = getAllThemes()
.map((t) => {
const selected = t.id === currentTheme ? 'selected' : '';
return `<option value="${t.id}" ${selected}>${t.name}</option>`;
})
.join('');
<div id="theme-preview" class="theme-preview"> appearanceHtml = `
<div class="preview-colors"> <div class="form-section ui-card ui-card-pad-lg ui-form">
<span class="preview-swatch preview-bg" title="Background"></span> <h2>Appearance</h2>
<span class="preview-swatch preview-surface" title="Surface"></span>
<span class="preview-swatch preview-primary" title="Primary"></span> <div class="form-group">
<span class="preview-swatch preview-success" title="Success"></span> <label for="theme-select">Theme</label>
<span class="preview-swatch preview-error" title="Error"></span> <select id="theme-select" class="theme-select">
${themeOptions}
</select>
<p class="hint">Choose a visual theme for the interface</p>
</div>
<div id="theme-preview" class="theme-preview">
<div class="preview-colors">
<span class="preview-swatch preview-bg" title="Background"></span>
<span class="preview-swatch preview-surface" title="Surface"></span>
<span class="preview-swatch preview-primary" title="Primary"></span>
<span class="preview-swatch preview-success" title="Success"></span>
<span class="preview-swatch preview-error" title="Error"></span>
</div>
</div> </div>
</div> </div>
</div> `;
}
container.innerHTML = `
${appearanceHtml}
<form id="profile-form" class="settings-form"> <form id="profile-form" class="settings-form">
<div class="form-section ui-card ui-card-pad-lg ui-form"> <div class="form-section ui-card ui-card-pad-lg ui-form">
@ -99,7 +121,9 @@ import { API_BASE as apiBase } from '../lib/api';
</div> </div>
`; `;
setupThemeSwitcher(); if (!publicDemoSite) {
setupThemeSwitcher();
}
document.getElementById('profile-form')?.addEventListener('submit', async (e) => { document.getElementById('profile-form')?.addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
@ -148,6 +172,8 @@ import { API_BASE as apiBase } from '../lib/api';
} }
function setupThemeSwitcher() { function setupThemeSwitcher() {
if (publicDemoSite) return;
function updatePreview() { function updatePreview() {
const previewBg = document.querySelector('.preview-bg'); const previewBg = document.querySelector('.preview-bg');
const previewSurface = document.querySelector('.preview-surface'); const previewSurface = document.querySelector('.preview-surface');

View file

@ -29,10 +29,10 @@ if (Test-Path $envFile) {
} }
Write-Host "`n[1/3] Stopping demo containers and removing volumes..." -ForegroundColor Yellow Write-Host "`n[1/3] Stopping demo containers and removing volumes..." -ForegroundColor Yellow
podman-compose @composeArgs down --remove-orphans -v podman compose @composeArgs down --remove-orphans -v
Write-Host "`n[2/3] Starting fresh demo instance..." -ForegroundColor Yellow Write-Host "`n[2/3] Starting fresh demo instance..." -ForegroundColor Yellow
podman-compose @composeArgs up -d podman compose @composeArgs up -d
Write-Host "`n[3/3] Waiting for services to be ready..." -ForegroundColor Yellow Write-Host "`n[3/3] Waiting for services to be ready..." -ForegroundColor Yellow
Start-Sleep -Seconds 10 Start-Sleep -Seconds 10
@ -63,4 +63,4 @@ while ($retry -lt $maxRetries) {
Write-Host "`nWarning: Backend health check timed out. Check logs with:" -ForegroundColor Yellow Write-Host "`nWarning: Backend health check timed out. Check logs with:" -ForegroundColor Yellow
$composeArgsText = ($composeArgs -join ' ') $composeArgsText = ($composeArgs -join ' ')
Write-Host " podman-compose $composeArgsText logs backend" Write-Host " podman compose $composeArgsText logs backend"

View file

@ -26,10 +26,10 @@ if [ "$1" != "--force" ] && [ "$1" != "-f" ]; then
fi fi
echo -e "\n[1/3] Stopping demo containers and removing volumes..." echo -e "\n[1/3] Stopping demo containers and removing volumes..."
podman-compose "${COMPOSE_ARGS[@]}" down --remove-orphans -v || docker-compose "${COMPOSE_ARGS[@]}" down --remove-orphans -v podman compose "${COMPOSE_ARGS[@]}" down --remove-orphans -v || podman-compose "${COMPOSE_ARGS[@]}" down --remove-orphans -v || docker-compose "${COMPOSE_ARGS[@]}" down --remove-orphans -v
echo -e "\n[2/3] Starting fresh demo instance..." echo -e "\n[2/3] Starting fresh demo instance..."
podman-compose "${COMPOSE_ARGS[@]}" up -d || docker-compose "${COMPOSE_ARGS[@]}" up -d podman compose "${COMPOSE_ARGS[@]}" up -d || podman-compose "${COMPOSE_ARGS[@]}" up -d || docker-compose "${COMPOSE_ARGS[@]}" up -d
echo -e "\n[3/3] Waiting for services to be ready..." echo -e "\n[3/3] Waiting for services to be ready..."
sleep 5 sleep 5
@ -54,5 +54,5 @@ while [ $retry -lt $max_retries ]; do
done done
echo -e "\nWarning: Backend health check timed out. Check logs with:" echo -e "\nWarning: Backend health check timed out. Check logs with:"
echo " podman-compose ${COMPOSE_ARGS[*]} logs backend" echo " podman compose ${COMPOSE_ARGS[*]} logs backend"
echo " docker-compose ${COMPOSE_ARGS[*]} logs backend" echo " docker-compose ${COMPOSE_ARGS[*]} logs backend"

View file

@ -27,19 +27,19 @@ function Invoke-Compose {
[Parameter(Mandatory=$true)][string[]]$Args [Parameter(Mandatory=$true)][string[]]$Args
) )
$podmanCompose = Get-Command podman-compose -ErrorAction SilentlyContinue
if ($podmanCompose) {
$null = & podman-compose @Args
return $LASTEXITCODE
}
$podman = Get-Command podman -ErrorAction SilentlyContinue $podman = Get-Command podman -ErrorAction SilentlyContinue
if ($podman) { if ($podman) {
$null = & podman compose @Args $null = & podman compose @Args
return $LASTEXITCODE return $LASTEXITCODE
} }
throw 'Neither podman-compose nor podman was found in PATH.' $podmanCompose = Get-Command podman-compose -ErrorAction SilentlyContinue
if ($podmanCompose) {
$null = & podman-compose @Args
return $LASTEXITCODE
}
throw 'Neither podman nor podman-compose was found in PATH.'
} }
function Get-CommandLine { function Get-CommandLine {

View file

@ -30,7 +30,7 @@ fi
# Start PostgreSQL # Start PostgreSQL
echo "Starting PostgreSQL..." echo "Starting PostgreSQL..."
podman-compose -f "$ROOT_DIR/compose/dev.yml" up -d 2>/dev/null || true podman compose -f "$ROOT_DIR/compose/dev.yml" up -d 2>/dev/null || podman-compose -f "$ROOT_DIR/compose/dev.yml" up -d 2>/dev/null || true
# Wait for PostgreSQL # Wait for PostgreSQL
echo "Waiting for PostgreSQL..." echo "Waiting for PostgreSQL..."

View file

@ -19,19 +19,19 @@ function Invoke-Compose {
[Parameter(Mandatory=$true)][string[]]$Args [Parameter(Mandatory=$true)][string[]]$Args
) )
$podmanCompose = Get-Command podman-compose -ErrorAction SilentlyContinue
if ($podmanCompose) {
$null = & podman-compose @Args
return $LASTEXITCODE
}
$podman = Get-Command podman -ErrorAction SilentlyContinue $podman = Get-Command podman -ErrorAction SilentlyContinue
if ($podman) { if ($podman) {
$null = & podman compose @Args $null = & podman compose @Args
return $LASTEXITCODE return $LASTEXITCODE
} }
throw 'Neither podman-compose nor podman was found in PATH.' $podmanCompose = Get-Command podman-compose -ErrorAction SilentlyContinue
if ($podmanCompose) {
$null = & podman-compose @Args
return $LASTEXITCODE
}
throw 'Neither podman nor podman-compose was found in PATH.'
} }
function Get-CommandLine { function Get-CommandLine {

View file

@ -48,7 +48,7 @@ pkill -f "astro dev" 2>/dev/null || true
# Stop PostgreSQL if requested # Stop PostgreSQL if requested
if [ "$STOP_DB" = "--all" ] || [ "$STOP_DB" = "-a" ]; then if [ "$STOP_DB" = "--all" ] || [ "$STOP_DB" = "-a" ]; then
echo "Stopping PostgreSQL..." echo "Stopping PostgreSQL..."
podman-compose -f "$ROOT_DIR/compose/dev.yml" down 2>/dev/null || true podman compose -f "$ROOT_DIR/compose/dev.yml" down 2>/dev/null || podman-compose -f "$ROOT_DIR/compose/dev.yml" down 2>/dev/null || true
fi fi
echo "Done." echo "Done."

View file

@ -1 +1,4 @@
podman-compose -f compose/dev.yml up podman compose -f compose/dev.yml up
if ($LASTEXITCODE -ne 0) {
podman-compose -f compose/dev.yml up
}

View file

@ -1,2 +1,2 @@
#!/bin/sh #!/bin/sh
podman-compose -f compose/dev.yml up podman compose -f compose/dev.yml up || podman-compose -f compose/dev.yml up

View file

@ -5,7 +5,7 @@ Write-Host "=== Likwid Post-Reboot Setup ===" -ForegroundColor Cyan
# Step 1: Verify WSL2 is working # Step 1: Verify WSL2 is working
Write-Host "`n[1/4] Checking WSL2 status..." -ForegroundColor Yellow Write-Host "`n[1/4] Checking WSL2 status..." -ForegroundColor Yellow
$wslStatus = wsl --status 2>&1 wsl --status 2>&1 | Out-Null
if ($LASTEXITCODE -ne 0) { if ($LASTEXITCODE -ne 0) {
Write-Host "WSL2 is not ready. Please ensure virtualization is enabled in BIOS." -ForegroundColor Red Write-Host "WSL2 is not ready. Please ensure virtualization is enabled in BIOS." -ForegroundColor Red
Write-Host "Run 'wsl --install --no-distribution' as administrator if needed." -ForegroundColor Red Write-Host "Run 'wsl --install --no-distribution' as administrator if needed." -ForegroundColor Red
@ -35,7 +35,7 @@ wsl --set-default openSUSE-Tumbleweed
# Step 4: Configure Podman in WSL2 # Step 4: Configure Podman in WSL2
Write-Host "`n[4/4] Configuring Podman in openSUSE Tumbleweed..." -ForegroundColor Yellow Write-Host "`n[4/4] Configuring Podman in openSUSE Tumbleweed..." -ForegroundColor Yellow
wsl -d openSUSE-Tumbleweed -e bash -c " wsl -d openSUSE-Tumbleweed -e bash -c "
echo 'Installing Podman and podman-compose...' echo 'Installing Podman and compose support (use: podman compose)'
sudo zypper refresh sudo zypper refresh
sudo zypper install -y podman podman-compose sudo zypper install -y podman podman-compose
@ -44,7 +44,7 @@ wsl -d openSUSE-Tumbleweed -e bash -c "
echo 'Verifying installation...' echo 'Verifying installation...'
podman --version podman --version
podman-compose --version podman compose --help >/dev/null 2>&1 || true
" "
Write-Host "`n=== Setup Complete ===" -ForegroundColor Cyan Write-Host "`n=== Setup Complete ===" -ForegroundColor Cyan

View file

@ -28,4 +28,4 @@ if (Test-Path $demoMigration) {
Write-Host "`n=== Production Preparation Complete ===" -ForegroundColor Green Write-Host "`n=== Production Preparation Complete ===" -ForegroundColor Green
Write-Host "`nNext steps:" Write-Host "`nNext steps:"
Write-Host " 1. Configure compose/.env.production" Write-Host " 1. Configure compose/.env.production"
Write-Host " 2. Run: podman-compose -f compose/production.yml up -d" Write-Host " 2. Run: podman compose -f compose/production.yml up -d"