commit 02794e565ef109d6273900b6b782148ca784172c Author: Protocolbot Date: Mon Jul 6 13:18:08 2026 -0600 feat: initial release of Protocol Droid v0.2.0 A tool for authoring, sharing, and curating social protocols. Features: - Protocol library with search, tag filtering, and sort by date/relevance - Protocol authoring with structured fields (title, description, steps, outcome, practice, source, tags) - Protocol forking with provenance tracking - Collection creation with searchable protocol picker and ordering - User accounts with roles (admin, member, viewer) - YAML import/export for portability - Self-hostable on LAMP/Cloudron, works in subdirectories - Responsive design with hamburger menu on mobile - About page Security: - CSRF protection via Origin/Referer validation - Session regeneration on login/register - Secure session cookie params (HttpOnly, SameSite, Secure) - Visibility enforcement on private/unlisted items - YAML object injection hardening - Login rate limiting - Path traversal protection - Input validation and length clamping - Vote value constraining Stack: PHP 8.x + MySQL/MariaDB, vanilla JS frontend, no external dependencies. Hippocratic License (HL3-CORE). diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea7d69f --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Docker +docker-compose.yml + +# Design sketches (not part of the app) +sketches/ + +# Runtime data +data/ +*.db +*.sqlite + +# Environment +.env + +# OS files +.DS_Store +Thumbs.db + +# Editor files +*.swp +*.swo +*~ +.vscode/ +.idea/ \ No newline at end of file diff --git a/.htaccess b/.htaccess new file mode 100755 index 0000000..63b097f --- /dev/null +++ b/.htaccess @@ -0,0 +1,19 @@ +# Protocol Droid — Apache rewrite rules +# Route /api/* to the PHP API and serve the frontend for everything else + +RewriteEngine On + +# Route API requests to api/index.php +RewriteRule ^api/?$ api/index.php [L] +RewriteRule ^api/(.*)$ api/index.php [L] + +# Serve frontend for the root and non-api paths +# (Apache will serve index.html, style.css, app.js directly if they exist) +RewriteCond %{REQUEST_FILENAME} !-f +RewriteCond %{REQUEST_FILENAME} !-d +RewriteRule ^$ frontend/index.html [L] + +# Fallback: serve frontend/index.html for SPA routes +RewriteCond %{REQUEST_FILENAME} !-f +RewriteCond %{REQUEST_FILENAME} !-d +RewriteRule ^ frontend/index.html [L] \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5dd5bdc --- /dev/null +++ b/Dockerfile @@ -0,0 +1,24 @@ +FROM php:8.2-apache + +# Install MySQL PDO extension and YAML extension +RUN docker-php-ext-install pdo pdo_mysql mysqli + +# Install PECL yaml extension +RUN apt-get update && apt-get install -y libyaml-dev && \ + pecl install yaml && docker-php-ext-enable yaml && \ + rm -rf /var/lib/apt/lists/* + +# Enable Apache rewrite module for .htaccess +RUN a2enmod rewrite + +# Set document root to our app +ENV APACHE_DOCUMENT_ROOT /var/www/html +RUN sed -ri -e 's!/var/www/html!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/sites-available/000-default.conf + +# Copy app files +COPY . /var/www/html/ + +# Ensure data directory is writable +RUN mkdir -p /var/www/html/data && chmod 777 /var/www/html/data + +EXPOSE 80 \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..88d4220 --- /dev/null +++ b/LICENSE @@ -0,0 +1,12 @@ +Hippocratic License (HL3-CORE) + +This project is licensed under the Hippocratic License v3.0 (HL3-CORE). + +Full text available at: https://firstdonoharm.dev/version/3/0/core.html + +Summary: Do no harm. This software may not be used by anyone or any +organization for any purpose that could cause harm — including but not +limited to physical harm, violations of human rights, environmental +damage, or surveillance. + +This is not legal advice. See the full license text for the complete terms. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..c530fa9 --- /dev/null +++ b/README.md @@ -0,0 +1,169 @@ +# Protocol Droid + +A tool for authoring, sharing, and curating social protocols. + +A project of the [Media Economies Design Lab at the University of Colorado Boulder](https://MEDLab.host). + +## Overview + +Protocol Droid is a web application where communities can document, share, and remix patterns of interaction — social protocols. It is self-hostable, whitelabel-able, and designed for organizations that want a trustful commons without external dependencies. + +## Features + +- **Protocol library**: browse protocols with structured fields (title, description, steps, outcome, practice, source) +- **Authoring**: create protocols through a form-based editor +- **Forking**: adapt an existing protocol to your context while linking back to the original +- **Collections**: curate sets of protocols and share them +- **Identity**: user accounts with optional SSO support +- **YAML import/export**: protocols are fully structured YAML — portable, git-diffable, human-readable +- **Whitelabel**: customize site name, tagline, and theme via `config.yaml` + +## Architecture + +``` +protocol-droid/ +├── api/ PHP backend (REST API) +│ ├── index.php API router (all /api/* requests) +│ ├── db.php Database connection + helpers (MySQL/MariaDB) +│ ├── auth.php Session-based authentication +│ ├── config.php Site configuration +│ └── yaml.php YAML import/export helpers +├── frontend/ Single-page frontend +│ ├── index.html HTML shell +│ ├── style.css R2-Rebel blend stylesheet +│ └── app.js Application logic (vanilla JS) +├── seeds/ Seed protocol YAML files +│ ├── round-robin-check-in.yaml +│ ├── consent-decision-making.yaml +│ ├── appreciative-apology.yaml +│ ├── temperature-reading.yaml +│ ├── dot-voting.yaml +│ └── fishbowl-discussion.yaml +├── cloudron/ Cloudron deployment files +│ └── CloudronManifest.json +├── schema.sql MySQL database schema +├── config.yaml Whitelabel configuration +├── .htaccess Apache rewrite rules +└── README.md This file +``` + +## Stack + +- **Backend**: PHP 8.x with PDO (MySQL/MariaDB) +- **Database**: MySQL/MariaDB (Cloudron-native) +- **Frontend**: Vanilla HTML/CSS/JS — no frameworks, no build step, no external dependencies +- **Data format**: YAML for portability, MySQL for runtime +- **Auth**: Session-based with optional SSO + +## Deployment + +### On Cloudron + +1. Create a LAMP app in Cloudron +2. Upload all files to the app's web root +3. The database schema auto-initializes on first API request +4. Visit the site and register — the **first user to register becomes admin** +5. Optionally load seed protocols: sign in as admin and `POST /api/seed` (or use the API endpoint with curl: `curl -b cookies.txt -X POST https://your-site/api/seed`) + +**No default admin credentials exist.** A fresh deployment has an empty database. The first registration creates the admin account. Seed protocols are optional and must be explicitly loaded. + +### On any LAMP server + +1. Upload files to your web root (or a subdirectory like `/protocol-droid/`) +2. Create a MySQL database and user +3. Set environment variables (or edit `api/db.php`): + - `MYSQL_HOST`, `MYSQL_PORT`, `MYSQL_DATABASE`, `MYSQL_USER`, `MYSQL_PASSWORD` +4. Visit the site and register — first user becomes admin + +**Subdirectory deployment:** Protocol Droid works in a subdirectory (e.g. `example.com/protocol-droid/`). The root `index.php` auto-detects the base path from the server's script name. No configuration needed — just upload the files to the subdirectory and ensure `index.php` is served as the directory index (most LAMP setups do this by default with `DirectoryIndex index.php` or `.htaccess`). + +### Local development + +```bash +# Start PHP built-in server +cd protocol-droid +php -S localhost:8000 + +# Or with a MySQL database: +MYSQL_HOST=localhost MYSQL_DATABASE=protocol_droid MYSQL_USER=root MYSQL_PASSWORD=secret php -S localhost:8000 +``` + +The first registration creates the admin account. To load the 6 sample protocols (Round Robin Check-In, Consent Decision-Making, etc.): + +```bash +# After registering as admin: +curl -c cookies.txt -X POST http://localhost:8000/api/auth/login \ + -H 'Content-Type: application/json' \ + -d '{"username":"your-username","password":"your-password"}' + +curl -b cookies.txt -X POST http://localhost:8000/api/seed +``` + +To remove all seed protocols (or any protocols), delete them individually: + +```bash +curl -b cookies.txt -X DELETE http://localhost:8000/api/protocols/round-robin-check-in +``` + +## API + +| Endpoint | Method | Description | Auth | +|----------|--------|-------------|------| +| `/api` | GET | API info | none | +| `/api/config` | GET | Site configuration | none | +| `/api/protocols` | GET | List public protocols | none | +| `/api/protocols/:slug` | GET | Get protocol | none | +| `/api/protocols` | POST | Create protocol | member | +| `/api/protocols/:slug` | PUT | Update protocol | author/admin | +| `/api/protocols/:slug` | DELETE | Delete protocol | author/admin | +| `/api/protocols/:slug/yaml` | GET | Export as YAML | none | +| `/api/collections` | GET | List collections | none | +| `/api/collections/:slug` | GET | Get collection | none | +| `/api/collections` | POST | Create collection | member | +| `/api/collections/:slug` | PUT | Update collection | author/admin | +| `/api/collections/:slug` | DELETE | Delete collection | author/admin | +| `/api/auth/register` | POST | Register | none | +| `/api/auth/login` | POST | Login | none | +| `/api/auth/logout` | POST | Logout | none | +| `/api/auth/me` | GET | Current user | none | +| `/api/users/:username` | GET | Public profile | none | +| `/api/votes` | POST | Vote on protocol | member | +| `/api/export` | GET | Export all as YAML | admin | +| `/api/import` | POST | Import YAML | admin | +| `/api/seed` | POST | Load seed protocols | admin | + +## Protocol YAML Format + +```yaml +id: round-robin-check-in +title: Round Robin Check-In +description: >- + A structured way for each person in a group to share briefly. +source: Group Works Deck +source_url: https://groupworksdeck.org/deck +tags: [check-in, facilitation] +forked_from: null +image: null +steps: + - headline: Frame the round + description: >- + The facilitator explains that each person will have up to one minute. + - headline: Go around + description: >- + Each person speaks in turn. Passing is always an option. +outcome: >- + Everyone has been heard; the group has a shared sense of where things stand. +practice: >- + Use a talking object. Keep time gently. For large groups, break into smaller rounds. +``` + +## License + +Hippocratic License (HL3-CORE) — do no harm. See https://firstdonoharm.dev/ + +## Roadmap + +- **SSO / OIDC** — The database schema and user table include `sso_provider` and `sso_id` fields, and the auth API has a placeholder SSO endpoint. Full OIDC provider support (university SSO, Google Workspace, etc.) is planned but not yet implemented. +- **Bicorder integration** — Connecting Protocol Droid with the [Protocol Bicorder](https://git.medlab.host/ntnsndr/protocol-bicorder) diagnostic tool, so protocols can be evaluated along gradient dimensions. +- **Voting / evaluation** — The `votes` table exists in the schema but the UI for voting on protocols is not yet built. +- **Image support** — The protocol and collection schemas include an `image` field, but image upload and display are not yet implemented. \ No newline at end of file diff --git a/api/auth.php b/api/auth.php new file mode 100755 index 0000000..48b181b --- /dev/null +++ b/api/auth.php @@ -0,0 +1,223 @@ + 0, + 'path' => '/', + 'httponly' => true, + 'samesite' => 'Lax', + 'secure' => !empty($_SERVER['HTTPS']) || getenv('FORCE_HTTPS'), +]); +session_start(); + +function current_user(): ?array { + if (!isset($_SESSION['user_id'])) return null; + $user = db_one("SELECT id, username, display_name, bio, role, created_at FROM users WHERE id = ?", [$_SESSION['user_id']]); + return $user ?: null; +} + +function require_auth(): array { + $user = current_user(); + if (!$user) { + http_response_code(401); + echo json_encode(['error' => 'Authentication required']); + exit; + } + return $user; +} + +function require_admin(): array { + $user = require_auth(); + if ($user['role'] !== 'admin') { + http_response_code(403); + echo json_encode(['error' => 'Admin access required']); + exit; + } + return $user; +} + +function require_member(): array { + $user = require_auth(); + if ($user['role'] === 'viewer') { + http_response_code(403); + echo json_encode(['error' => 'Member access required']); + exit; + } + return $user; +} + +// --- CSRF protection --- +function check_csrf(): void { + $method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; + if (!in_array($method, ['POST', 'PUT', 'DELETE'])) return; + + $origin = $_SERVER['HTTP_ORIGIN'] ?? ''; + $referer = $_SERVER['HTTP_REFERER'] ?? ''; + $expected_host = $_SERVER['HTTP_HOST'] ?? ''; + + if ($origin) { + $origin_host = parse_url($origin, PHP_URL_HOST); + if ($origin_host !== $expected_host) { + http_response_code(403); + echo json_encode(['error' => 'Cross-origin requests are not allowed']); + exit; + } + } elseif ($referer) { + $referer_host = parse_url($referer, PHP_URL_HOST); + if ($referer_host !== $expected_host) { + http_response_code(403); + echo json_encode(['error' => 'Cross-origin requests are not allowed']); + exit; + } + } +} + +// --- Rate limiting for login --- +function check_login_rate(string $username): void { + $ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown'; + $key = 'login_attempts_' . md5($ip . $username); + $file = sys_get_temp_dir() . '/' . $key; + $attempts = file_exists($file) ? (int)file_get_contents($file) : 0; + if ($attempts >= 10) { + http_response_code(429); + echo json_encode(['error' => 'Too many login attempts. Try again later.']); + exit; + } +} + +function record_login_failure(string $username): void { + $ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown'; + $key = 'login_attempts_' . md5($ip . $username); + $file = sys_get_temp_dir() . '/' . $key; + $attempts = file_exists($file) ? (int)file_get_contents($file) : 0; + file_put_contents($file, ($attempts + 1)); + touch($file, time() + 900); +} + +function clear_login_rate(string $username): void { + $ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown'; + $key = 'login_attempts_' . md5($ip . $username); + $file = sys_get_temp_dir() . '/' . $key; + if (file_exists($file)) unlink($file); +} + +function handle_register(): void { + $data = json_body(); + $username = trim($data['username'] ?? ''); + $password = $data['password'] ?? ''; + $display_name = trim($data['display_name'] ?? ''); + + if (!preg_match('/^[a-zA-Z0-9_]{3,32}$/', $username)) { + respond(400, ['error' => 'Username must be 3-32 characters: letters, numbers, underscore']); + return; + } + if (strlen($password) < 8 || strlen($password) > 72) { + respond(400, ['error' => 'Password must be 8-72 characters']); + return; + } + if (db_one("SELECT id FROM users WHERE username = ?", [$username])) { + respond(409, ['error' => 'Username already taken']); + return; + } + + $user_count = (int) db_one("SELECT COUNT(*) as c FROM users")['c']; + $role = 'member'; + + if ($user_count === 0) { + $bootstrap_token = getenv('ADMIN_BOOTSTRAP_TOKEN'); + if ($bootstrap_token) { + $provided = $data['bootstrap_token'] ?? ''; + if ($provided !== $bootstrap_token) { + respond(403, ['error' => 'First registration requires an admin bootstrap token. Set ADMIN_BOOTSTRAP_TOKEN env var and include it in the registration request.']); + return; + } + $role = 'admin'; + } else { + error_log('Protocol Droid: First user registered as admin without bootstrap token. Set ADMIN_BOOTSTRAP_TOKEN for production.'); + $role = 'admin'; + } + } + + if ($user_count > 0) { + $reg_enabled = db_one("SELECT value FROM config WHERE `key` = 'registration_enabled'"); + if ($reg_enabled && $reg_enabled['value'] !== 'true') { + respond(403, ['error' => 'Registration is disabled on this instance']); + return; + } + $require_approval = config_get('require_approval', 'false') === 'true'; + if ($require_approval) { + $role = 'viewer'; + } + } + + $id = db_insert('users', [ + 'username' => $username, + 'display_name' => $display_name ?: $username, + 'password_hash' => password_hash($password, PASSWORD_DEFAULT), + 'role' => $role, + ]); + + session_regenerate_id(true); + $_SESSION['user_id'] = $id; + respond(201, [ + 'id' => $id, + 'username' => $username, + 'display_name' => $display_name ?: $username, + 'role' => $role, + ]); +} + +function handle_login(): void { + $data = json_body(); + $username = trim($data['username'] ?? ''); + $password = $data['password'] ?? ''; + + check_login_rate($username); + + $user = db_one("SELECT * FROM users WHERE username = ?", [$username]); + if (!$user || !password_verify($password, $user['password_hash'] ?? '')) { + record_login_failure($username); + respond(401, ['error' => 'Invalid username or password']); + return; + } + + clear_login_rate($username); + session_regenerate_id(true); + $_SESSION['user_id'] = $user['id']; + respond(200, [ + 'id' => $user['id'], + 'username' => $user['username'], + 'display_name' => $user['display_name'], + 'role' => $user['role'], + ]); +} + +function handle_logout(): void { + $_SESSION = []; + session_destroy(); + if (ini_get('session.use_cookies')) { + $params = session_get_cookie_params(); + setcookie(session_name(), '', time() - 42000, + $params['path'], $params['domain'], + $params['secure'], $params['httponly'] + ); + } + respond(200, ['ok' => true]); +} + +function handle_me(): void { + $user = current_user(); + if (!$user) { + respond(200, ['user' => null]); + return; + } + respond(200, ['user' => $user]); +} + +function handle_sso_callback(): void { + respond(501, ['error' => 'SSO not configured on this instance. Configure SSO in api/auth.php']); +} \ No newline at end of file diff --git a/api/config.php b/api/config.php new file mode 100755 index 0000000..0aac893 --- /dev/null +++ b/api/config.php @@ -0,0 +1,51 @@ + config_get('site_name', 'Protocol Droid'), + 'tagline' => config_get('site_tagline', 'A commons of social protocols'), + 'registration_enabled' => config_get('registration_enabled', 'true') === 'true', + 'require_approval' => config_get('require_approval', 'false') === 'true', + ]; +} + +/** + * Load a config.yaml file if present (for whitelabel customization). + * Merges with database config (database takes precedence). + */ +function load_yaml_config(): array { + $path = __DIR__ . '/../config.yaml'; + if (!file_exists($path)) return []; + $yaml_text = file_get_contents($path); + return yaml_parse($yaml_text) ?: []; +} + +function merged_config(): array { + $yaml_cfg = load_yaml_config(); + $db_cfg = site_config(); + return array_merge($yaml_cfg, $db_cfg); +} \ No newline at end of file diff --git a/api/db.php b/api/db.php new file mode 100755 index 0000000..a854fe3 --- /dev/null +++ b/api/db.php @@ -0,0 +1,98 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ]); + } catch (PDOException $e) { + http_response_code(500); + echo json_encode(['error' => 'Database connection failed']); + exit; + } + } + return $pdo; +} + +function db_init(): void { + $sql = file_get_contents(__DIR__ . '/../schema.sql'); + if (!$sql) return; + // Split on semicolons at end of statements (simple split — schema has no complex strings with semicolons) + $statements = array_filter(array_map('trim', preg_split('/;\s*(?=\n|$)/', $sql))); + foreach ($statements as $stmt) { + if (empty($stmt)) continue; + try { + db()->exec($stmt); + } catch (PDOException $e) { + // Ignore "already exists" errors + if (strpos($e->getMessage(), 'already exists') === false) { + error_log("Schema init: " . $e->getMessage()); + } + } + } +} + +function db_query(string $sql, array $params = []): PDOStatement { + $stmt = db()->prepare($sql); + $stmt->execute($params); + return $stmt; +} + +function db_one(string $sql, array $params = []): ?array { + $stmt = db_query($sql, $params); + $row = $stmt->fetch(); + return $row ?: null; +} + +function db_all(string $sql, array $params = []): array { + $stmt = db_query($sql, $params); + return $stmt->fetchAll(); +} + +function db_insert(string $table, array $data): int { + $cols = implode(', ', array_map(fn($c) => "`$c`", array_keys($data))); + $placeholders = implode(', ', array_fill(0, count($data), '?')); + $sql = "INSERT INTO {$table} ({$cols}) VALUES ({$placeholders})"; + db_query($sql, array_values($data)); + return (int) db()->lastInsertId(); +} + +function db_update(string $table, array $data, string $where, array $whereParams): int { + $set = implode(', ', array_map(fn($c) => "`$c` = ?", array_keys($data))); + $sql = "UPDATE {$table} SET {$set} WHERE {$where}"; + db_query($sql, array_merge(array_values($data), $whereParams)); + return db_query("SELECT ROW_COUNT()")->fetchColumn(); +} + +function slugify(string $text): string { + $slug = strtolower($text); + $slug = preg_replace('/[^a-z0-9]+/', '-', $slug); + $slug = trim($slug, '-'); + $slug = substr($slug, 0, 128); + return $slug ?: 'untitled'; +} + +function ensure_unique_slug(string $base): string { + $slug = $base; + $i = 1; + while (db_one("SELECT slug FROM protocols WHERE slug = ?", [$slug]) || + db_one("SELECT slug FROM collections WHERE slug = ?", [$slug])) { + $slug = $base . '-' . $i++; + } + return $slug; +} \ No newline at end of file diff --git a/api/index.php b/api/index.php new file mode 100755 index 0000000..77def9a --- /dev/null +++ b/api/index.php @@ -0,0 +1,515 @@ + $row['slug'], + 'title' => $row['title'], + 'description' => $row['description'] ?? '', + 'source' => $row['source'] ?? '', + 'source_url' => $row['source_url'] ?? '', + 'tags' => json_decode_field($row['tags'] ?? null), + 'forked_from' => $row['forked_from'] ?? null, + 'image' => $row['image'] ?? null, + 'steps' => json_decode_field($row['steps'] ?? null), + 'outcome' => $row['outcome'] ?? '', + 'practice' => $row['practice'] ?? '', + 'author' => $row['author_username'] ?? null, + 'author_id' => $row['author_id'] ?? null, + 'visibility' => $row['visibility'] ?? 'public', + 'created_at' => $row['created_at'] ?? null, + 'updated_at' => $row['updated_at'] ?? null, + ]; +} + +function format_collection(array $row, array $items): array { + return [ + 'slug' => $row['slug'], + 'title' => $row['title'], + 'description' => $row['description'] ?? '', + 'source' => $row['source'] ?? '', + 'image' => $row['image'] ?? null, + 'author' => $row['author_username'] ?? null, + 'author_id' => $row['author_id'] ?? null, + 'visibility' => $row['visibility'] ?? 'public', + 'items' => array_map(fn($i) => [ + 'type' => $i['item_type'], + 'slug' => $i['item_slug'], + ], $items), + 'created_at' => $row['created_at'] ?? null, + 'updated_at' => $row['updated_at'] ?? null, + ]; +} + +function format_collection_summary(array $row, int $item_count): array { + return [ + 'slug' => $row['slug'], + 'title' => $row['title'], + 'description' => $row['description'] ?? '', + 'source' => $row['source'] ?? '', + 'image' => $row['image'] ?? null, + 'author' => $row['author_username'] ?? null, + 'author_id' => $row['author_id'] ?? null, + 'visibility' => $row['visibility'] ?? 'public', + 'item_count' => $item_count, + 'created_at' => $row['created_at'] ?? null, + 'updated_at' => $row['updated_at'] ?? null, + ]; +} + +// --- Routing --- + +$parts = parse_path(); +$method = $_SERVER['REQUEST_METHOD']; + +// Empty path = API root +if (empty($parts)) { + respond(200, [ + 'name' => 'Protocol Droid API', + 'version' => '0.2.0', + 'endpoints' => '/api/protocols, /api/collections, /api/auth/*, /api/users/*', + ]); +} + +// --- Auth routes --- +if ($parts[0] === 'auth') { + $action = $parts[1] ?? ''; + if ($action === 'register' && $method === 'POST') handle_register(); + elseif ($action === 'login' && $method === 'POST') handle_login(); + elseif ($action === 'logout' && $method === 'POST') handle_logout(); + elseif ($action === 'me' && $method === 'GET') handle_me(); + elseif ($action === 'sso' && $method === 'POST') handle_sso_callback(); + else respond(404, ['error' => 'Unknown auth endpoint']); +} + +// --- Config --- +if ($parts[0] === 'config' && $method === 'GET') { + respond(200, merged_config()); +} + +// --- Protocols --- +if ($parts[0] === 'protocols') { + $slug = $parts[1] ?? null; + + if (!$slug) { + // List protocols + if ($method !== 'GET') respond(405, ['error' => 'Method not allowed']); + + $where = "WHERE p.visibility = 'public'"; + $params = []; + + $tag = $_GET['tag'] ?? null; + if ($tag) { + // Use JSON_CONTAINS for accurate tag matching (MySQL 5.7+) + $where .= " AND JSON_CONTAINS(p.tags, JSON_QUOTE(?))"; + $params[] = $tag; + } + + $q = $_GET['q'] ?? null; + if ($q) { + $where .= " AND (p.title LIKE ? OR p.description LIKE ? OR p.source LIKE ?)"; + $params = array_merge($params, ["%$q%", "%$q%", "%$q%"]); + } + + $sql = "SELECT p.*, u.username AS author_username + FROM protocols p LEFT JOIN users u ON p.author_id = u.id + $where ORDER BY p.created_at DESC"; + $rows = db_all($sql, $params); + respond(200, array_map('format_protocol', $rows)); + } + + // YAML export + if (($parts[2] ?? '') === 'yaml' && $method === 'GET') { + $row = db_one("SELECT * FROM protocols WHERE slug = ?", [$slug]); + if (!$row) respond(404, ['error' => 'Protocol not found']); + $safe_slug = sanitize_slug($slug); + header('Content-Type: text/yaml'); + header('Content-Disposition: attachment; filename="' . $safe_slug . '.yaml"'); + echo protocol_to_yaml($row); + exit; + } + + // Votes for a protocol + if (($parts[2] ?? '') === 'votes' && $method === 'GET') { + // Check protocol exists and is public (or user is author/admin) + $row = db_one("SELECT * FROM protocols WHERE slug = ?", [$slug]); + if (!$row) respond(404, ['error' => 'Protocol not found']); + $user = current_user(); + if ($row['visibility'] !== 'public' && (!$user || ($row['author_id'] != $user['id'] && $user['role'] !== 'admin'))) { + respond(404, ['error' => 'Protocol not found']); + } + $votes = db_all( + "SELECT v.value, v.comment, v.created_at, u.username FROM votes v LEFT JOIN users u ON v.user_id = u.id WHERE v.protocol_slug = ?", + [$slug] + ); + $score = array_sum(array_column($votes, 'value')); + respond(200, ['score' => $score, 'votes' => $votes]); + } + + // Single protocol — enforce visibility + if ($method === 'GET') { + $row = db_one( + "SELECT p.*, u.username AS author_username FROM protocols p LEFT JOIN users u ON p.author_id = u.id WHERE p.slug = ?", + [$slug] + ); + if (!$row) respond(404, ['error' => 'Protocol not found']); + $user = current_user(); + if ($row['visibility'] !== 'public' && (!$user || ($row['author_id'] != $user['id'] && $user['role'] !== 'admin'))) { + respond(404, ['error' => 'Protocol not found']); + } + respond(200, format_protocol($row)); + } + + // Create protocol + if ($method === 'POST') { + $user = require_member(); + $data = json_body(); + + $new_slug = ensure_unique_slug($data['slug'] ?? slugify($data['title'] ?? 'untitled')); + $id = db_insert('protocols', [ + 'slug' => $new_slug, + 'title' => substr($data['title'] ?? '', 0, 255), + 'description' => $data['description'] ?? '', + 'source' => $data['source'] ?? ('@' . $user['username']), + 'source_url' => substr($data['source_url'] ?? '', 0, 500), + 'tags' => json_encode(array_slice($data['tags'] ?? [], 0, 20)), + 'forked_from' => $data['forked_from'] ?? null, + 'image' => $data['image'] ?? null, + 'steps' => json_encode(array_slice($data['steps'] ?? [], 0, 50)), + 'outcome' => $data['outcome'] ?? '', + 'practice' => $data['practice'] ?? '', + 'author_id' => $user['id'], + 'visibility' => validate_visibility($data['visibility'] ?? 'public'), + ]); + $row = db_one("SELECT p.*, u.username AS author_username FROM protocols p LEFT JOIN users u ON p.author_id = u.id WHERE p.id = ?", [$id]); + respond(201, format_protocol($row)); + } + + // Update protocol — auth before existence check + if ($method === 'PUT') { + $user = require_auth(); + $row = db_one("SELECT * FROM protocols WHERE slug = ?", [$slug]); + if (!$row) respond(404, ['error' => 'Protocol not found']); + if ($row['author_id'] != $user['id'] && $user['role'] !== 'admin') { + respond(403, ['error' => 'You can only edit your own protocols']); + } + + $data = json_body(); + $updates = []; + foreach (['title', 'description', 'source', 'source_url', 'forked_from', 'image', 'outcome', 'practice'] as $field) { + if (array_key_exists($field, $data)) $updates[$field] = $data[$field]; + } + if (array_key_exists('tags', $data)) $updates['tags'] = json_encode(array_slice($data['tags'] ?? [], 0, 20)); + if (array_key_exists('steps', $data)) $updates['steps'] = json_encode(array_slice($data['steps'] ?? [], 0, 50)); + if (array_key_exists('visibility', $data)) $updates['visibility'] = validate_visibility($data['visibility']); + if ($updates) { + db_update('protocols', $updates, 'slug = ?', [$slug]); + } + $row = db_one("SELECT p.*, u.username AS author_username FROM protocols p LEFT JOIN users u ON p.author_id = u.id WHERE p.slug = ?", [$slug]); + respond(200, format_protocol($row)); + } + + // Delete protocol — auth before existence check + if ($method === 'DELETE') { + $user = require_auth(); + $row = db_one("SELECT * FROM protocols WHERE slug = ?", [$slug]); + if (!$row) respond(404, ['error' => 'Protocol not found']); + if ($row['author_id'] != $user['id'] && $user['role'] !== 'admin') { + respond(403, ['error' => 'You can only delete your own protocols']); + } + + // Clean up orphaned votes and collection items + db_query("DELETE FROM votes WHERE protocol_slug = ?", [$slug]); + db_query("DELETE FROM collection_items WHERE item_type = 'protocol' AND item_slug = ?", [$slug]); + db_query("DELETE FROM protocols WHERE slug = ?", [$slug]); + respond(200, ['ok' => true]); + } + + respond(405, ['error' => 'Method not allowed']); +} + +// --- Collections --- +if ($parts[0] === 'collections') { + $slug = $parts[1] ?? null; + + if (!$slug) { + // List collections + if ($method === 'GET') { + $rows = db_all( + "SELECT c.*, u.username AS author_username FROM collections c LEFT JOIN users u ON c.author_id = u.id WHERE c.visibility = 'public' ORDER BY c.created_at DESC" + ); + $result = []; + foreach ($rows as $row) { + $items = db_all("SELECT * FROM collection_items WHERE collection_id = ?", [$row['id']]); + $result[] = format_collection_summary($row, count($items)); + } + respond(200, $result); + } + // Create collection + if ($method === 'POST') { + $user = require_member(); + $data = json_body(); + $new_slug = ensure_unique_slug($data['slug'] ?? slugify($data['title'] ?? 'untitled')); + $id = db_insert('collections', [ + 'slug' => $new_slug, + 'title' => substr($data['title'] ?? '', 0, 255), + 'description' => $data['description'] ?? '', + 'source' => $data['source'] ?? ('@' . $user['username']), + 'image' => $data['image'] ?? null, + 'author_id' => $user['id'], + 'visibility' => validate_visibility($data['visibility'] ?? 'public'), + ]); + $position = 0; + foreach (($data['items'] ?? []) as $item) { + db_insert('collection_items', [ + 'collection_id' => $id, + 'item_type' => $item['type'] ?? 'protocol', + 'item_slug' => $item['slug'] ?? '', + 'position' => $position++, + ]); + } + $row = db_one("SELECT c.*, u.username AS author_username FROM collections c LEFT JOIN users u ON c.author_id = u.id WHERE c.id = ?", [$id]); + $items = db_all("SELECT * FROM collection_items WHERE collection_id = ? ORDER BY position", [$id]); + respond(201, format_collection($row, $items)); + } + respond(405, ['error' => 'Method not allowed']); + } + + // Single collection — enforce visibility + if ($method === 'GET') { + $row = db_one( + "SELECT c.*, u.username AS author_username FROM collections c LEFT JOIN users u ON c.author_id = u.id WHERE c.slug = ?", + [$slug] + ); + if (!$row) respond(404, ['error' => 'Collection not found']); + $user = current_user(); + if ($row['visibility'] !== 'public' && (!$user || ($row['author_id'] != $user['id'] && $user['role'] !== 'admin'))) { + respond(404, ['error' => 'Collection not found']); + } + $items = db_all("SELECT * FROM collection_items WHERE collection_id = ? ORDER BY position", [$row['id']]); + respond(200, format_collection($row, $items)); + } + + // Update collection — auth before existence check + if ($method === 'PUT') { + $user = require_auth(); + $row = db_one("SELECT * FROM collections WHERE slug = ?", [$slug]); + if (!$row) respond(404, ['error' => 'Collection not found']); + if ($row['author_id'] != $user['id'] && $user['role'] !== 'admin') { + respond(403, ['error' => 'You can only edit your own collections']); + } + + $data = json_body(); + $updates = []; + foreach (['title', 'description', 'source', 'image'] as $field) { + if (array_key_exists($field, $data)) $updates[$field] = $data[$field]; + } + if (array_key_exists('visibility', $data)) $updates['visibility'] = validate_visibility($data['visibility']); + if ($updates) db_update('collections', $updates, 'slug = ?', [$slug]); + + // Update items if provided + if (isset($data['items'])) { + $col_id = $row['id']; + db_query("DELETE FROM collection_items WHERE collection_id = ?", [$col_id]); + $position = 0; + foreach ($data['items'] as $item) { + db_insert('collection_items', [ + 'collection_id' => $col_id, + 'item_type' => $item['type'] ?? 'protocol', + 'item_slug' => $item['slug'] ?? '', + 'position' => $position++, + ]); + } + } + + $row = db_one("SELECT c.*, u.username AS author_username FROM collections c LEFT JOIN users u ON c.author_id = u.id WHERE c.slug = ?", [$slug]); + $items = db_all("SELECT * FROM collection_items WHERE collection_id = ? ORDER BY position", [$row['id']]); + respond(200, format_collection($row, $items)); + } + + // Delete collection — auth before existence check + if ($method === 'DELETE') { + $user = require_auth(); + $row = db_one("SELECT * FROM collections WHERE slug = ?", [$slug]); + if (!$row) respond(404, ['error' => 'Collection not found']); + if ($row['author_id'] != $user['id'] && $user['role'] !== 'admin') { + respond(403, ['error' => 'You can only delete your own collections']); + } + db_query("DELETE FROM collection_items WHERE collection_id = ?", [$row['id']]); + db_query("DELETE FROM collections WHERE slug = ?", [$slug]); + respond(200, ['ok' => true]); + } + + respond(405, ['error' => 'Method not allowed']); +} + +// --- Users --- +if ($parts[0] === 'users') { + $username = $parts[1] ?? null; + if (!$username || $method !== 'GET') respond(404, ['error' => 'Unknown endpoint']); + + $user = db_one("SELECT id, username, display_name, bio, created_at FROM users WHERE username = ?", [$username]); + if (!$user) respond(404, ['error' => 'User not found']); + + // Fetch full protocol data for user's public protocols + $protocols = db_all( + "SELECT p.*, u.username AS author_username FROM protocols p LEFT JOIN users u ON p.author_id = u.id WHERE p.author_id = ? AND p.visibility = 'public' ORDER BY p.created_at DESC", + [$user['id']] + ); + $collections = db_all("SELECT slug, title, description FROM collections WHERE author_id = ? AND visibility = 'public' ORDER BY created_at DESC", [$user['id']]); + + respond(200, [ + 'user' => $user, + 'protocols' => array_map('format_protocol', $protocols), + 'collections' => $collections, + ]); +} + +// --- Votes --- +if ($parts[0] === 'votes' && $method === 'POST') { + $user = require_member(); + $data = json_body(); + $protocol_slug = $data['protocol_slug'] ?? ''; + // Constrain value to -1, 0, or 1 + $value = (int)($data['value'] ?? 1); + $value = $value > 0 ? 1 : ($value < 0 ? -1 : 0); + $comment = substr($data['comment'] ?? '', 0, 1000); + + // Check protocol exists and is public + $proto = db_one("SELECT * FROM protocols WHERE slug = ?", [$protocol_slug]); + if (!$proto) respond(404, ['error' => 'Protocol not found']); + if ($proto['visibility'] !== 'public') respond(404, ['error' => 'Protocol not found']); + + // Upsert vote + db_query( + "INSERT INTO votes (user_id, protocol_slug, value, comment) VALUES (?, ?, ?, ?) + ON DUPLICATE KEY UPDATE value = VALUES(value), comment = VALUES(comment)", + [$user['id'], $protocol_slug, $value, $comment] + ); + respond(200, ['ok' => true]); +} + +// --- Import / Export / Seed --- +if ($parts[0] === 'export' && $method === 'GET') { + require_admin(); + $protocols = db_all("SELECT * FROM protocols WHERE visibility = 'public'"); + $output = "# Protocol Droid Export\n\n"; + foreach ($protocols as $p) { + $output .= "---\n\n" . protocol_to_yaml($p) . "\n"; + } + header('Content-Type: text/yaml'); + header('Content-Disposition: attachment; filename="protocol-droid-export.yaml"'); + echo $output; + exit; +} + +if ($parts[0] === 'import' && $method === 'POST') { + require_admin(); + $data = json_body(); + $yaml_text = $data['yaml'] ?? ''; + $parsed = parse_protocol_yaml($yaml_text); + if (!$parsed) respond(400, ['error' => 'Could not parse YAML']); + + $parsed['author_id'] = current_user()['id']; + $parsed['slug'] = ensure_unique_slug($parsed['slug']); + $parsed['visibility'] = validate_visibility($parsed['visibility'] ?? 'public'); + $id = db_insert('protocols', $parsed); + respond(201, ['id' => $id, 'slug' => $parsed['slug']]); +} + +if ($parts[0] === 'seed' && $method === 'POST') { + require_admin(); + $seeds_dir = __DIR__ . '/../seeds'; + if (!is_dir($seeds_dir)) respond(404, ['error' => 'No seeds directory']); + + $count = 0; + foreach (glob($seeds_dir . '/*.yaml') as $file) { + $yaml_text = file_get_contents($file); + $parsed = parse_protocol_yaml($yaml_text); + if (!$parsed) continue; + + if (db_one("SELECT slug FROM protocols WHERE slug = ?", [$parsed['slug']])) continue; + + db_insert('protocols', $parsed); + $count++; + } + respond(200, ['imported' => $count]); +} + +respond(404, ['error' => 'Unknown endpoint: /' . implode('/', $parts)]); \ No newline at end of file diff --git a/api/yaml.php b/api/yaml.php new file mode 100755 index 0000000..5bae01a --- /dev/null +++ b/api/yaml.php @@ -0,0 +1,203 @@ + $i['item_type'] === 'collection'); + if ($nested) { + $lines[] = "collections:"; + foreach ($nested as $item) { + $lines[] = " - " . $item['item_slug']; + } + } + return implode("\n", $lines) . "\n"; +} + +function yaml_quote(string $s): string { + if ($s === '') return '""'; + if (preg_match('/[:#{}&*!|>"\'\[\]]/', $s) || preg_match('/^\s/', $s)) { + $s = str_replace('"', '\\"', $s); + return '"' . $s . '"'; + } + return $s; +} + +function yaml_multiline(string $s, int $indent = 0): string { + if ($s === '') return '""'; + $pad = str_repeat(' ', $indent); + // Use folded scalar for readability + $lines = explode("\n", trim($s)); + if (count($lines) <= 1 && strlen($s) < 60) { + return yaml_quote($s); + } + $out = ">-"; + foreach ($lines as $line) { + $out .= "\n" . $pad . " " . $line; + } + return $out; +} + +function yaml_array(array $arr): string { + if (empty($arr)) return '[]'; + return '[' . implode(', ', array_map('yaml_quote', $arr)) . ']'; +} + +/** + * Parse a YAML protocol file into a database-ready array. + * Uses yaml_parse if available, otherwise a simple parser for the protocol format. + */ +function parse_protocol_yaml(string $yaml_text): ?array { + if (function_exists('yaml_parse')) { + // Harden against PHP object injection via !php/object tags + $data = yaml_parse($yaml_text, -1, $ndocs, [ + '!php/object' => false, + '!php/const' => false, + ]); + if (!is_array($data)) return null; + } else { + $data = simple_yaml_parse($yaml_text); + if (!is_array($data)) return null; + } + + $steps = []; + foreach (($data['steps'] ?? []) as $step) { + $steps[] = [ + 'headline' => $step['headline'] ?? '', + 'description' => $step['description'] ?? '', + ]; + } + + return [ + 'slug' => $data['id'] ?? slugify($data['title'] ?? 'untitled'), + 'title' => $data['title'] ?? '', + 'description' => $data['description'] ?? '', + 'source' => $data['source'] ?? '', + 'source_url' => $data['source_url'] ?? '', + 'tags' => json_encode($data['tags'] ?? []), + 'forked_from' => $data['forked_from'] ?? null, + 'image' => $data['image'] ?? null, + 'steps' => json_encode($steps), + 'outcome' => $data['outcome'] ?? '', + 'practice' => $data['practice'] ?? '', + ]; +} + +/** + * Minimal YAML parser for simple protocol files. + * Handles: top-level key: value, nested lists with - headline:, folded scalars + * This is NOT a full YAML parser — it handles the protocol format only. + */ +function simple_yaml_parse(string $text): ?array { + $result = []; + $lines = explode("\n", $text); + $current_key = null; + $in_steps = false; + $in_folded = false; + $folded_buffer = ''; + $folded_key = null; + $folded_indent = 0; + + $flush_folded = function() use (&$result, &$folded_buffer, &$folded_key, &$in_folded) { + if ($in_folded && $folded_key !== null) { + $result[$folded_key] = trim($folded_buffer); + $folded_buffer = ''; + $in_folded = false; + $folded_key = null; + } + }; + + foreach ($lines as $line) { + // Skip comments and empty lines + if (preg_match('/^\s*#/', $line) || trim($line) === '') continue; + + // Folded scalar continuation + if ($in_folded) { + if (preg_match('/^(\s{' . ($folded_indent + 2) . ',})/', $line, $m)) { + $folded_buffer .= ' ' . trim($line); + continue; + } else { + $flush_folded(); + } + } + + // Steps list items + if (preg_match('/^(\s+) - headline:\s*(.*)$/', $line, $m)) { + $in_steps = true; + $result['steps'][] = ['headline' => trim($m[2], '"'), 'description' => '']; + $current_key = 'description'; + continue; + } + if (preg_match('/^(\s+) description:\s*(.*)$/', $line, $m)) { + $val = trim($m[2], '"'); + if (str_starts_with($val, '>-') || str_starts_with($val, '|-')) { + $in_folded = true; + $folded_key = null; // Will set on last step + $folded_buffer = ''; + $folded_indent = strlen($m[1]); + } else { + if (!empty($result['steps'])) { + $result['steps'][count($result['steps']) - 1]['description'] = $val; + } + } + continue; + } + + // Top-level key: value + if (preg_match('/^([a-z_]+):\s*(.*)$/', $line, $m)) { + $flush_folded(); + $key = $m[1]; + $val = trim($m[2]); + if ($val === '' ) { + $current_key = $key; + if ($key === 'steps') { $result['steps'] = []; $in_steps = true; } + continue; + } + // Parse inline array [a, b, c] + if (str_starts_with($val, '[') && str_ends_with($val, ']')) { + $inner = substr($val, 1, -1); + $result[$key] = array_map(fn($s) => trim($s, ' "'), array_filter(explode(',', $inner), fn($s) => trim($s) !== '')); + continue; + } + $result[$key] = trim($val, '"'); + continue; + } + } + $flush_folded(); + + return $result; +} \ No newline at end of file diff --git a/cloudron/CloudronManifest.json b/cloudron/CloudronManifest.json new file mode 100644 index 0000000..c5b29c3 --- /dev/null +++ b/cloudron/CloudronManifest.json @@ -0,0 +1,26 @@ +{ + "id": "org.medlab.protocoldroid", + "title": "Protocol Droid", + "author": "Media Economies Design Lab, CU Boulder", + "description": "A tool for authoring, sharing, and curating social protocols. Self-hostable and whitelabel-able.", + "tagline": "A commons of social protocols", + "version": "0.1.0", + "healthCheckPath": "/api", + "httpPort": 8000, + "manifestVersion": 2, + "website": "https://medlab.host", + "contactEmail": "medlab@colorado.edu", + "icon": "file://icon.png", + "addons": { + "mysql": { + "noDatabase": false + } + }, + "tcpPorts": {}, + "udpPorts": {}, + "capabilities": [], + "minBoxVersion": "8.0.0", + "documentationUrl": "https://github.com/medlab/protocol-droid", + "tags": ["collaboration", "governance", "protocols", "commons"], + "mediaLinks": [] +} \ No newline at end of file diff --git a/config.yaml b/config.yaml new file mode 100755 index 0000000..0042b38 --- /dev/null +++ b/config.yaml @@ -0,0 +1,21 @@ +# Protocol Droid Configuration +# This file is for whitelabel customization. +# Database config (in the config table) takes precedence over values here. + +site: + name: Protocol Droid # becomes the header logo text + tagline: A commons of social protocols + +registration: + enabled: true # allow open registration + require_approval: false # admin must approve new accounts + +sso: + enabled: false + provider: oidc # oidc, github, google, custom + client_id: "" + client_secret: "" + discover_url: "" + +# Seed protocols are loaded from seeds/*.yaml on first setup +# or when an admin triggers /api/seed \ No newline at end of file diff --git a/frontend/app.js b/frontend/app.js new file mode 100755 index 0000000..b72f1a2 --- /dev/null +++ b/frontend/app.js @@ -0,0 +1,838 @@ +/* Protocol Droid — Frontend Application */ + +const API = '/api'; +let currentUser = null; +let siteConfig = {}; +let allTags = []; +let activeFilter = 'ALL'; +let editingSlug = null; +let editingCollectionSlug = null; +let collectionSelectedProtocols = []; +let pickerProtocolList = []; +let currentTags = []; +let allExistingTags = []; + +// --- API helpers --- +async function api(path, options = {}) { + const res = await fetch(API + path, { + ...options, + headers: { 'Content-Type': 'application/json', ...options.headers }, + credentials: 'same-origin', + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || 'Request failed'); + return data; +} + +// --- Init --- +async function init() { + // Route immediately — don't wait for API calls + route(); + window.addEventListener('hashchange', route); + + try { + siteConfig = await api('/config'); + var tagline = document.getElementById('libTagline'); + if (tagline) tagline.textContent = siteConfig.tagline || ''; + document.title = siteConfig.name || 'Protocol Droid'; + } catch (e) {} + + try { + const { user } = await api('/auth/me'); + currentUser = user; + } catch (e) {} + renderAuth(); + + // Re-route after auth loads so auth-dependent UI renders correctly + if (location.hash && location.hash !== '#library') { + route(); + } +} + +function route() { + const hash = location.hash.slice(1) || 'library'; + if (hash.startsWith('protocols/')) { + showDetail(hash.split('/')[1]); + } else if (hash.startsWith('users/') || hash.startsWith('user/')) { + showUser(hash.split('/')[1]); + } else if (hash.startsWith('collections/')) { + showCollectionDetail(hash.split('/')[1]); + } else if (hash === 'create') { + navigate('create'); + } else if (hash === 'create-collection') { + navigate('create-collection'); + } else if (hash === 'collections') { + navigate('collections'); + } else if (hash === 'about') { + navigate('about'); + } else { + navigate('library'); + } +} + +let searchTimer = null; +let currentRoute = ''; + +function navigate(view) { + // Prevent double navigation from hashchange + if (currentRoute === view) return; + currentRoute = view; + document.querySelectorAll('.view').forEach(v => v.style.display = 'none'); + document.getElementById('view-' + view).style.display = 'block'; + document.querySelectorAll('header nav a').forEach(a => a.classList.toggle('active', a.dataset.view === view)); + document.querySelectorAll('.mobile-panel a').forEach(a => a.classList.toggle('active', a.dataset.view === view)); + closeMenu(); + updateBreadcrumb(view); + if (view === 'library') { location.hash = 'library'; loadProtocols(); } + if (view === 'collections') { location.hash = 'collections'; loadCollections(); } + if (view === 'create') { editingSlug = null; resetForm(); } + if (view === 'create-collection') { editingCollectionSlug = null; resetCollectionForm(); } + if (view === 'about') { location.hash = 'about'; } + window.scrollTo(0, 0); +} + +// --- Mobile menu --- +function updateBreadcrumb(view) { + const path = document.getElementById('crumbPath'); + const map = { library: '', create: 'create', collections: 'collections/', 'create-collection': 'collections/create', about: 'about' }; + path.textContent = map[view] || ''; +} + +// --- Mobile menu --- +function toggleMenu() { + document.getElementById('mobilePanel').classList.toggle('open'); +} +function closeMenu() { + document.getElementById('mobilePanel').classList.remove('open'); +} +function mobileNav(view) { + navigate(view); +} + +// --- Auth --- +function renderAuth() { + const area = document.getElementById('authArea'); + const mobileAuth = document.getElementById('mobileAuth'); + if (currentUser) { + area.innerHTML = '@' + currentUser.username + '' + + ''; + mobileAuth.innerHTML = '@' + currentUser.username + '' + + ''; + } else { + area.innerHTML = '' + + ''; + mobileAuth.innerHTML = '' + + ''; + } +} + +let authMode = 'login'; +function openAuthModal(mode) { + authMode = mode; + document.getElementById('authModal').style.display = 'flex'; + document.getElementById('authModalTitle').textContent = mode === 'register' ? 'Create Account' : 'Sign In'; + document.getElementById('authSubmit').textContent = mode === 'register' ? 'Register' : 'Sign In'; + document.getElementById('displayNameGroup').style.display = mode === 'register' ? 'block' : 'none'; + document.getElementById('authToggle').innerHTML = mode === 'register' + ? 'Already have an account? Sign in' + : 'No account? Register'; +} +function closeAuthModal() { document.getElementById('authModal').style.display = 'none'; } + +async function handleAuth(e) { + e.preventDefault(); + const username = document.getElementById('authUsername').value; + const password = document.getElementById('authPassword').value; + const displayName = document.getElementById('authDisplayName').value; + try { + let data; + if (authMode === 'register') { + data = await api('/auth/register', { method: 'POST', body: JSON.stringify({ username, password, display_name: displayName }) }); + } else { + data = await api('/auth/login', { method: 'POST', body: JSON.stringify({ username, password }) }); + } + currentUser = data; + renderAuth(); + closeAuthModal(); + toast('Welcome, @' + data.username + '!'); + } catch (err) { + toast(err.message, true); + } +} + +async function logout() { + await api('/auth/logout', { method: 'POST' }); + currentUser = null; + renderAuth(); + toast('Signed out'); +} + +// --- Protocols --- +let allProtocols = []; +let sortBy = 'date'; + +function debouncedLoadProtocols() { + if (searchTimer) clearTimeout(searchTimer); + searchTimer = setTimeout(loadProtocols, 200); +} + +async function loadProtocols() { + const q = document.getElementById('searchInput') ? document.getElementById('searchInput').value : ''; + try { + // Fetch all protocols — we do priority-weighted search client-side + allProtocols = await api('/protocols'); + const tagSet = new Set(); + allProtocols.forEach(p => (p.tags || []).forEach(t => tagSet.add(t))); + allTags = [...tagSet].sort(); + renderFilters(); + + // Filter by tag + let filtered = allProtocols; + if (activeFilter !== 'ALL') { + filtered = filtered.filter(p => (p.tags || []).some(t => t.toLowerCase() === activeFilter.toLowerCase())); + } + + // Filter + score by search query + if (q.trim()) { + var query = q.toLowerCase().trim(); + var scored = filtered.map(function(p) { + var score = 0; + if ((p.title || '').toLowerCase().includes(query)) score += 100; + if ((p.tags || []).some(function(t) { return t.toLowerCase().includes(query); })) score += 50; + if ((p.source || '').toLowerCase().includes(query)) score += 30; + if ((p.author || '').toLowerCase().includes(query)) score += 30; + if ((p.description || '').toLowerCase().includes(query)) score += 10; + if ((p.steps || []).some(function(s) { + return (s.headline || '').toLowerCase().includes(query) || (s.description || '').toLowerCase().includes(query); + })) score += 5; + if ((p.outcome || '').toLowerCase().includes(query)) score += 3; + if ((p.practice || '').toLowerCase().includes(query)) score += 3; + return { p: p, score: score }; + }).filter(function(item) { return item.score > 0; }); + if (sortBy === 'relevance') { + scored.sort(function(a, b) { return b.score - a.score; }); + } else { + scored.sort(function(a, b) { return (b.p.created_at || '').localeCompare(a.p.created_at || ''); }); + } + filtered = scored.map(function(item) { return item.p; }); + } else { + // No search query — sort by date (newest first) + filtered = filtered.slice().sort(function(a, b) { + return (b.created_at || '').localeCompare(a.created_at || ''); + }); + } + + renderProtocolGrid(filtered); + renderSortToggle(); + } catch (e) { + document.getElementById('protocolGrid').innerHTML = '
No protocols found. ' + escapeHtml(e.message) + '
'; + } +} + +function setSort(mode) { + sortBy = mode; + document.querySelectorAll('.sort-option').forEach(function(el) { + el.classList.toggle('active', el.textContent.trim().startsWith(mode === 'date' ? 'date' : 'relevance')); + }); + loadProtocols(); +} + +function renderSortToggle() { + // Update active state based on current sortBy + document.querySelectorAll('.sort-option').forEach(function(el) { + var isDate = el.textContent.trim().startsWith('date'); + el.classList.toggle('active', (sortBy === 'date' && isDate) || (sortBy === 'relevance' && !isDate)); + }); +} + +function renderFilters() { + const bar = document.getElementById('filterBar'); + if (!bar) return; + var q = document.getElementById('tagSearch') ? document.getElementById('tagSearch').value.toLowerCase().trim() : ''; + var tags = allTags.slice().sort(); + if (q) { + tags = tags.filter(function(t) { return t.toLowerCase().includes(q); }); + } else { + tags = tags.slice(0, 10); // Show top 10 tags by default + } + tags = ['all', ...tags]; + bar.innerHTML = tags.map(function(t) { + var display = t === 'all' ? 'all' : t; + return '' + escapeHtml(display) + ''; + }).join(''); +} + +function toggleFilterPanel() { + var panel = document.getElementById('filterPanel'); + var toggle = document.querySelector('.filter-toggle'); + panel.classList.toggle('open'); + toggle.classList.toggle('active', panel.classList.contains('open')); +} + +function renderFilteredTags() { + renderFilters(); +} + +function setFilter(tag) { + activeFilter = tag; + loadProtocols(); +} + +function renderProtocolGrid(protocols) { + const grid = document.getElementById('protocolGrid'); + if (!protocols.length) { + grid.innerHTML = '
No protocols yet. Create one
'; + return; + } + grid.innerHTML = protocols.map(function(p) { + var sourceLabel = p.source || (p.author ? '@' + p.author : ''); + var tags = (p.tags || []).map(function(t) { return '' + escapeHtml(t) + ''; }).join(''); + return '
' + + '

' + escapeHtml(p.title) + '

' + + '

' + escapeHtml(p.description || '') + '

' + + (tags ? '
' + tags + '
' : '') + '
' + + '
' + (p.steps || []).length + ' steps
' + + '
' + escapeHtml(sourceLabel) + '
'; + }).join(''); +} + +// (iconFor removed — icons are no longer used) + +// --- Detail --- +async function showDetail(slug) { + location.hash = 'protocols/' + slug; + document.querySelectorAll('.view').forEach(v => v.style.display = 'none'); + document.getElementById('view-detail').style.display = 'block'; + document.querySelectorAll('header nav a').forEach(a => a.classList.remove('active')); + document.querySelectorAll('.mobile-panel a').forEach(a => a.classList.remove('active')); + document.getElementById('crumbPath').textContent = 'protocols/' + slug; + closeMenu(); + + try { + const p = await api('/protocols/' + slug); + document.getElementById('detailContent').innerHTML = renderDetail(p); + renderDetailActions(p); + } catch (e) { + document.getElementById('detailContent').innerHTML = '
Protocol not found
'; + document.getElementById('detailActions').innerHTML = ''; + } +} + +function renderDetail(p) { + const tags = (p.tags || []).map(t => '' + escapeHtml(t) + '').join(''); + const steps = (p.steps || []).map(function(s, i) { + return '
' + String(i+1).padStart(2,'0') + '
' + + '

' + escapeHtml(s.headline) + '

' + escapeHtml(s.description || '') + '

'; + }).join(''); + const forkNotice = p.forked_from ? '
forked from: ' + p.forked_from + '
' : ''; + const sourceLine = p.source ? 'SOURCE: ' + escapeHtml(p.source) : ''; + var sourceLink = p.source_url ? ' · view_original' : ''; + var authorLine = p.author ? ' · authored by @' + escapeHtml(p.author) + '' : ''; + var dateLine = p.created_at ? ' · added ' + formatDate(p.created_at) : ''; + + return '
' + tags + '
' + + '

' + escapeHtml(p.title) + '

' + + '

' + escapeHtml(p.description || '') + '

' + + '
' + sourceLine + sourceLink + authorLine + dateLine + '
' + + forkNotice + '
' + + (steps ? '

Steps

' + steps + '
' : '') + + (p.outcome ? '

Outcome

' + escapeHtml(p.outcome) + '

' : '') + + (p.practice ? '

Practice

' + escapeHtml(p.practice) + '

' : ''); +} + +function renderDetailActions(p) { + const actions = document.getElementById('detailActions'); + const canEdit = currentUser && (p.author_id == currentUser.id || currentUser.role === 'admin'); + let html = '' + + ''; + if (canEdit) { + html += '' + + ''; + } + actions.innerHTML = html; +} + +function forkProtocol(slug) { + if (!currentUser) { toast('Sign in to fork protocols', true); return; } + api('/protocols/' + slug).then(function(p) { + navigate('create'); + editingSlug = null; + document.querySelector('[name=title]').value = p.title + ' (Fork)'; + document.querySelector('[name=description]').value = p.description || ''; + currentTags = [...(p.tags || [])]; + renderTagChips(); + loadExistingTags(); + document.querySelector('[name=source]').value = '@' + currentUser.username; + document.querySelector('[name=source_url]').value = p.source_url || ''; + document.querySelector('[name=outcome]').value = p.outcome || ''; + document.querySelector('[name=practice]').value = p.practice || ''; + editingSlug = '__fork__' + slug; + document.getElementById('stepsEditor').innerHTML = ''; + (p.steps || []).forEach(function(s) { addStep(s.headline, s.description); }); + toast('Forking protocol — edit and save to create your version'); + }); +} + +function editProtocol(slug) { + api('/protocols/' + slug).then(function(p) { + navigate('create'); + editingSlug = slug; + document.querySelector('[name=title]').value = p.title; + document.querySelector('[name=description]').value = p.description || ''; + currentTags = [...(p.tags || [])]; + renderTagChips(); + loadExistingTags(); + document.querySelector('[name=source]').value = p.source || ''; + document.querySelector('[name=source_url]').value = p.source_url || ''; + document.querySelector('[name=outcome]').value = p.outcome || ''; + document.querySelector('[name=practice]').value = p.practice || ''; + document.querySelector('[name=visibility]').value = p.visibility || 'public'; + document.getElementById('stepsEditor').innerHTML = ''; + (p.steps || []).forEach(function(s) { addStep(s.headline, s.description); }); + }); +} + +async function deleteProtocol(slug) { + if (!confirm('Delete this protocol? This cannot be undone.')) return; + try { + await api('/protocols/' + slug, { method: 'DELETE' }); + toast('Protocol deleted'); + navigate('library'); + } catch (e) { toast(e.message, true); } +} + +function exportYaml(slug) { + window.open(API + '/protocols/' + slug + '/yaml', '_blank'); +} + +// --- Tag management --- +async function loadExistingTags() { + try { + var protocols = await api('/protocols'); + var tagSet = new Set(); + protocols.forEach(function(p) { (p.tags || []).forEach(function(t) { tagSet.add(t); }); }); + allExistingTags = [...tagSet].sort(); + } catch (e) { allExistingTags = []; } +} + +function renderTagChips() { + var container = document.getElementById('tagChips'); + container.innerHTML = currentTags.map(function(t, i) { + return '' + escapeHtml(t) + ''; + }).join(''); +} + +function addTag(tag) { + tag = tag.trim().toLowerCase(); + if (!tag || currentTags.includes(tag)) return; + currentTags.push(tag); + renderTagChips(); + document.getElementById('tagInput').value = ''; + hideTagSuggestions(); +} + +function removeTag(i) { + currentTags.splice(i, 1); + renderTagChips(); +} + +function handleTagKeydown(e) { + if (e.key === 'Enter' || e.key === ',') { + e.preventDefault(); + var val = e.target.value.trim(); + if (val) addTag(val); + } else if (e.key === 'Backspace' && !e.target.value && currentTags.length) { + removeTag(currentTags.length - 1); + } +} + +function showTagSuggestions() { + var q = document.getElementById('tagInput').value.trim().toLowerCase(); + if (!q) { hideTagSuggestions(); return; } + var matches = allExistingTags.filter(function(t) { return t.includes(q) && !currentTags.includes(t); }).slice(0, 6); + var el = document.getElementById('tagSuggestions'); + if (!matches.length) { hideTagSuggestions(); return; } + el.innerHTML = matches.map(function(t) { + return '
' + escapeHtml(t) + '
'; + }).join(''); + el.style.display = 'block'; +} + +function hideTagSuggestions() { + document.getElementById('tagSuggestions').style.display = 'none'; +} + +// --- Create/Edit --- +function resetForm() { + document.getElementById('protocolForm').reset(); + document.getElementById('stepsEditor').innerHTML = ''; + editingSlug = null; + currentTags = []; + renderTagChips(); + loadExistingTags(); +} + +function addStep(headline, description) { + headline = headline || ''; + description = description || ''; + const editor = document.getElementById('stepsEditor'); + const div = document.createElement('div'); + div.className = 'step-editor'; + const num = editor.children.length + 1; + div.innerHTML = + '
> step ' + String(num).padStart(2,'0') + '
' + + '
' + + '
' + + '
' + + '
' + + ''; + editor.appendChild(div); + renumberSteps(); +} + +function renumberSteps() { + document.querySelectorAll('#stepsEditor .step-editor').forEach(function(el, i) { + el.querySelector('.step-num').textContent = '> step ' + String(i+1).padStart(2,'0'); + }); +} + +async function saveProtocol(e) { + e.preventDefault(); + if (!currentUser) { toast('Sign in to create protocols', true); openAuthModal('login'); return; } + + const form = e.target; + const forkedFrom = editingSlug && editingSlug.startsWith('__fork__') ? editingSlug.replace('__fork__', '') : null; + const isEdit = editingSlug && !forkedFrom; + + const data = { + title: form.title.value, + description: form.description.value, + tags: currentTags.slice(), + source: form.source.value || '@' + currentUser.username, + source_url: form.source_url.value, + outcome: form.outcome.value, + practice: form.practice.value, + visibility: form.visibility.value, + forked_from: forkedFrom, + steps: [...document.querySelectorAll('#stepsEditor .step-editor')].map(function(el) { + return { + headline: el.querySelector('.step-headline').value, + description: el.querySelector('.step-desc').value, + }; + }), + }; + + try { + if (isEdit) { + await api('/protocols/' + editingSlug, { method: 'PUT', body: JSON.stringify(data) }); + toast('Protocol updated'); + showDetail(editingSlug); + } else { + const created = await api('/protocols', { method: 'POST', body: JSON.stringify(data) }); + toast('Protocol created'); + showDetail(created.slug); + } + } catch (err) { toast(err.message, true); } +} + +// --- Collections --- +async function loadCollections() { + try { + var collections = await api('/collections'); + var q = document.getElementById('collectionSearch') ? document.getElementById('collectionSearch').value.toLowerCase().trim() : ''; + if (q) { + collections = collections.filter(function(c) { + return (c.title || '').toLowerCase().includes(q) || (c.description || '').toLowerCase().includes(q) || (c.author || '').toLowerCase().includes(q); + }); + } + var grid = document.getElementById('collectionsGrid'); + if (!collections.length) { + grid.innerHTML = '
No collections yet. Create one
'; + return; + } + grid.innerHTML = collections.map(function(c) { + var sourceLabel = c.author ? '@' + c.author : (c.source || ''); + return '
' + + '

' + escapeHtml(c.title) + '

' + escapeHtml(c.description || '') + '

' + + '
' + (c.item_count || 0) + ' protocols
' + escapeHtml(sourceLabel) + '
'; + }).join(''); + } catch (e) { + document.getElementById('collectionsGrid').innerHTML = '
' + escapeHtml(e.message) + '
'; + } +} + +function resetCollectionForm() { + document.getElementById('collectionForm').reset(); + collectionSelectedProtocols = []; + loadProtocolPicker(); +} + +async function loadProtocolPicker(selectedSlugs) { + selectedSlugs = selectedSlugs || []; + collectionSelectedProtocols = [...selectedSlugs]; + const picker = document.getElementById('collProtocolPicker'); + picker.innerHTML = + '
' + + '' + + '
Search for protocols above to add them.
'; + try { + pickerProtocolList = await api('/protocols'); + } catch (e) { + pickerProtocolList = []; + } + // Don't render the list — it starts empty, user searches to add +} + +function renderPickerList(protocols) { + const list = document.getElementById('pickerList'); + if (!protocols.length) { + list.innerHTML = '
No matching protocols found.
'; + return; + } + list.innerHTML = '
' + protocols.map(function(p) { + var selected = collectionSelectedProtocols.includes(p.slug); + var sourceLabel = p.source || (p.author ? '@' + p.author : ''); + var tags = (p.tags || []).map(function(t) { return '' + escapeHtml(t) + ''; }).join(''); + return '
' + + '

' + escapeHtml(p.title) + '

' + + '

' + escapeHtml(p.description || '') + '

' + + (tags ? '
' + tags + '
' : '') + '
' + + '
' + (p.steps || []).length + ' steps
' + + '
' + escapeHtml(sourceLabel) + '
'; + }).join('') + '
'; +} + +function toggleProtocolSelectClick(slug, el) { + var i = collectionSelectedProtocols.indexOf(slug); + if (i >= 0) { + collectionSelectedProtocols.splice(i, 1); + el.classList.remove('selected'); + } else { + collectionSelectedProtocols.push(slug); + el.classList.add('selected'); + } + renderSelectedList(); +} + +function renderSelectedList() { + var container = document.getElementById('selectedList'); + if (!container) return; + if (!collectionSelectedProtocols.length) { + container.innerHTML = ''; + return; + } + container.innerHTML = '
Selected protocols (' + collectionSelectedProtocols.length + ')
' + + collectionSelectedProtocols.map(function(slug, i) { + // Find protocol in the cached list for display + var p = pickerProtocolList.find(function(proto) { return proto.slug === slug; }); + var title = p ? escapeHtml(p.title) : escapeHtml(slug); + return '
' + + '' + (i + 1) + '' + + '' + title + '' + + '' + + '' + + '' + + '
'; + }).join(''); +} + +function moveSelected(i, dir) { + var j = i + dir; + if (j < 0 || j >= collectionSelectedProtocols.length) return; + var tmp = collectionSelectedProtocols[i]; + collectionSelectedProtocols[i] = collectionSelectedProtocols[j]; + collectionSelectedProtocols[j] = tmp; + renderSelectedList(); + // Update picker highlight if visible + refreshPickerHighlight(); +} + +function removeSelected(i) { + var slug = collectionSelectedProtocols[i]; + collectionSelectedProtocols.splice(i, 1); + renderSelectedList(); + refreshPickerHighlight(); +} + +function refreshPickerHighlight() { + document.querySelectorAll('.picker-pill').forEach(function(el) { + var slug = el.dataset.slug; + if (slug) { + el.classList.toggle('selected', collectionSelectedProtocols.includes(slug)); + } + }); +} + +function filterPickerProtocols() { + var q = document.getElementById('pickerSearch').value.toLowerCase().trim(); + var filtered = pickerProtocolList.filter(function(p) { + return !q || p.title.toLowerCase().includes(q) || (p.description || '').toLowerCase().includes(q) || (p.tags || []).some(function(t) { return t.toLowerCase().includes(q); }); + }); + renderPickerList(filtered); +} + +// (checkbox-based toggle removed — now using toggleProtocolSelectClick) + +async function saveCollection(e) { + e.preventDefault(); + if (!currentUser) { toast('Sign in to create collections', true); openAuthModal('login'); return; } + + const form = e.target; + var data = { + title: form['coll-title'].value, + description: form['coll-description'].value, + source: form['coll-source'].value || '@' + currentUser.username, + visibility: form['coll-visibility'].value, + items: collectionSelectedProtocols.map(function(slug) { return { type: 'protocol', slug: slug }; }), + }; + + try { + if (editingCollectionSlug) { + await api('/collections/' + editingCollectionSlug, { method: 'PUT', body: JSON.stringify(data) }); + toast('Collection updated'); + showCollectionDetail(editingCollectionSlug); + } else { + var created = await api('/collections', { method: 'POST', body: JSON.stringify(data) }); + toast('Collection created'); + showCollectionDetail(created.slug); + } + } catch (err) { toast(err.message, true); } +} + +async function showCollectionDetail(slug) { + location.hash = 'collections/' + slug; + document.querySelectorAll('.view').forEach(v => v.style.display = 'none'); + document.getElementById('view-collection-detail').style.display = 'block'; + document.querySelectorAll('header nav a').forEach(a => a.classList.remove('active')); + document.querySelectorAll('.mobile-panel a').forEach(a => a.classList.remove('active')); + document.getElementById('crumbPath').textContent = 'collections/' + slug; + closeMenu(); + + try { + var c = await api('/collections/' + slug); + var itemsHtml = ''; + for (var i = 0; i < (c.items || []).length; i++) { + var item = c.items[i]; + if (item.type === 'protocol') { + try { + var p = await api('/protocols/' + item.slug); + itemsHtml += '
' + + '

' + escapeHtml(p.title) + '

' + + '

' + escapeHtml(p.description || '') + '

' + + (p.tags && p.tags.length ? '
' + p.tags.map(function(t) { return '' + escapeHtml(t) + ''; }).join('') + '
' : '') + '
' + + '
' + (p.steps || []).length + ' steps
'; + } catch (e2) { + itemsHtml += '

' + escapeHtml(item.slug) + '

Protocol not found

'; + } + } + } + var sourceLine = c.source ? 'SOURCE: ' + escapeHtml(c.source) : ''; + var authorLine = c.author ? ' · authored by @' + escapeHtml(c.author) + '' : ''; + var dateLine = c.created_at ? ' · added ' + formatDate(c.created_at) : ''; + + document.getElementById('collectionDetailContent').innerHTML = + '

' + escapeHtml(c.title) + '

' + + '

' + escapeHtml(c.description || '') + '

' + + '
' + sourceLine + authorLine + dateLine + '
' + + (itemsHtml ? '

Protocols (' + (c.items || []).length + ')

' + itemsHtml + '
' : '
No protocols in this collection yet.
'); + + var canEdit = currentUser && (c.author_id == currentUser.id || currentUser.role === 'admin'); + var actionsHtml = ''; + if (canEdit) { + actionsHtml += '' + + ''; + } + document.getElementById('collectionDetailActions').innerHTML = actionsHtml; + } catch (e) { + document.getElementById('collectionDetailContent').innerHTML = '
Collection not found
'; + document.getElementById('collectionDetailActions').innerHTML = ''; + } +} + +function editCollection(slug) { + api('/collections/' + slug).then(function(c) { + navigate('create-collection'); + editingCollectionSlug = slug; + document.querySelector('[name=coll-title]').value = c.title; + document.querySelector('[name=coll-description]').value = c.description || ''; + document.querySelector('[name=coll-source]').value = c.source || ''; + document.querySelector('[name=coll-visibility]').value = c.visibility || 'public'; + var selectedSlugs = (c.items || []).filter(function(i) { return i.type === 'protocol'; }).map(function(i) { return i.slug; }); + loadProtocolPicker(selectedSlugs).then(function() { renderSelectedList(); }); + }); +} + +async function deleteCollection(slug) { + if (!confirm('Delete this collection? This cannot be undone.')) return; + try { + await api('/collections/' + slug, { method: 'DELETE' }); + toast('Collection deleted'); + navigate('collections'); + } catch (e) { toast(e.message, true); } +} + +// --- User Profile --- +async function showUser(username) { + document.querySelectorAll('.view').forEach(v => v.style.display = 'none'); + document.getElementById('view-detail').style.display = 'block'; + document.querySelectorAll('header nav a').forEach(a => a.classList.remove('active')); + document.querySelectorAll('.mobile-panel a').forEach(a => a.classList.remove('active')); + document.getElementById('crumbPath').textContent = 'users/' + username; + closeMenu(); + try { + const data = await api('/users/' + username); + const p = data.protocols || []; + const c = data.collections || []; + document.getElementById('detailContent').innerHTML = + '

@' + escapeHtml(data.user.username) + '

' + + (data.user.display_name ? '
' + escapeHtml(data.user.display_name) + '
' : '') + + (data.user.bio ? '
' + escapeHtml(data.user.bio) + '
' : '') + '
' + + (p.length ? '

Protocols (' + p.length + ')

' + + p.map(function(proto) { + return '
' + + '

' + escapeHtml(proto.title) + '

' + + '

' + escapeHtml(proto.description||'') + '

' + + (proto.tags && proto.tags.length ? '
' + proto.tags.map(function(t) { return '' + escapeHtml(t) + ''; }).join('') + '
' : '') + '
' + + '
' + (proto.steps || []).length + ' steps
'; + }).join('') + + '
' : '') + + (c.length ? '

Collections (' + c.length + ')

' + + c.map(function(col) { + return '
[+]
' + + '

' + escapeHtml(col.title) + '

' + escapeHtml(col.description||'') + '

'; + }).join('') + + '
' : ''); + document.getElementById('detailActions').innerHTML = ''; + } catch (e) { + document.getElementById('detailContent').innerHTML = '
User not found
'; + } +} + +// --- Toast --- +let toastTimer = null; +function toast(msg, isError) { + const el = document.getElementById('toast'); + el.textContent = msg; + el.style.display = 'block'; + el.style.borderColor = isError ? '#c0392b' : 'var(--accent-bright)'; + if (toastTimer) clearTimeout(toastTimer); + toastTimer = setTimeout(function() { el.style.display = 'none'; }, 3000); +} + +// --- Util --- +function escapeHtml(s) { + if (s === null || s === undefined) return ''; + return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"').replace(/'/g,'''); +} + +function formatDate(s) { + if (!s) return ''; + // Normalize MySQL DATETIME format (space-separated) to ISO + var d = new Date(String(s).replace(' ', 'T')); + if (isNaN(d)) return s; + var months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; + return months[d.getMonth()] + ' ' + d.getDate() + ', ' + d.getFullYear(); +} + +// --- Boot --- +init(); \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html new file mode 100755 index 0000000..d2c19b4 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,237 @@ + + + + + +Protocol Droid + + + + + +
+ + +
+ +
+ +
+ PROTOCOLS + COLLECTIONS + ABOUT +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + \ No newline at end of file diff --git a/frontend/style.css b/frontend/style.css new file mode 100755 index 0000000..f8bb197 --- /dev/null +++ b/frontend/style.css @@ -0,0 +1,530 @@ +/* Protocol Droid — R2-Rebel Blend Stylesheet */ +* { box-sizing: border-box; margin: 0; padding: 0; } +:root { + --bg: #f0f4f8; + --bg-grid: #e2e8f0; + --surface: #ffffff; + --panel: #e8edf2; + --ink: #0a1929; + --ink-soft: #4a5568; + --ink-dim: #8896a8; + --accent: #0066cc; + --accent-bright: #00aaff; + --accent-deep: #003d7a; + --green: #00875a; + --green-bright: #00cc88; + --border: #b0bec5; + --border-light: #d0d8e0; + --shadow: 0 2px 8px rgba(0,50,100,0.06); + --shadow-hover: 0 4px 16px rgba(0,102,204,0.10); + --mono: 'SF Mono', 'Courier New', 'Courier', monospace; + --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; +} +body { + font-family: var(--sans); + background: var(--bg); + background-image: + linear-gradient(var(--bg-grid) 1px, transparent 1px), + linear-gradient(90deg, var(--bg-grid) 1px, transparent 1px); + background-size: 32px 32px; + color: var(--ink); + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} + +/* Header */ +header { + background: var(--ink); + color: #fff; + padding: 0 16px; + min-height: 52px; + display: flex; + align-items: center; + gap: 12px; + border-bottom: 2px solid var(--accent-bright); + position: relative; + flex-wrap: nowrap; +} +header::after { + content: ''; + position: absolute; + bottom: -2px; + left: 0; right: 0; + height: 1px; + background: linear-gradient(90deg, var(--green-bright), var(--accent-bright), transparent); +} +/* Breadcrumb — two-line header: logo on top, location below */ +header .breadcrumb { + font-family: var(--mono); + font-size: 14px; + letter-spacing: 0.5px; + display: flex; + flex-direction: column; + gap: 0; + color: var(--accent-bright); + cursor: pointer; + flex-shrink: 1; + min-width: 0; + overflow: hidden; + white-space: nowrap; + line-height: 1.2; +} +header .breadcrumb .prompt { color: var(--green-bright); font-weight: normal; } +header .breadcrumb:hover { text-decoration: none; } +header .crumb-path { + font-size: 11px; + color: var(--ink-dim); + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; + cursor: pointer; +} + +/* Desktop nav — inline */ +header nav { + margin-left: auto; + display: flex; + gap: 4px; + flex-shrink: 0; + align-items: center; +} +header nav a { + color: #8896a8; + text-decoration: none; + font-size: 12px; + font-family: var(--mono); + padding: 6px 12px; + border: 1px solid transparent; + transition: all 0.15s; + letter-spacing: 0.5px; + white-space: nowrap; +} +header nav a:hover { color: #fff; border-color: rgba(255,255,255,0.15); } +header nav a.active { color: var(--accent-bright); border-color: rgba(0,170,255,0.3); background: rgba(0,170,255,0.08); } + +.auth-area { display: flex; align-items: center; gap: 8px; flex-shrink: 0; } +.auth-area .user-link { + color: var(--green-bright); + font-family: var(--mono); + font-size: 12px; + text-decoration: none; + white-space: nowrap; +} +.auth-area .auth-btn { + font-family: var(--mono); + font-size: 12px; + padding: 6px 12px; + border: 1px solid rgba(255,255,255,0.2); + background: transparent; + color: #fff; + cursor: pointer; + border-radius: 4px; + white-space: nowrap; +} +.auth-area .auth-btn:hover { background: rgba(255,255,255,0.1); } + +/* Hamburger button — hidden on desktop */ +.hamburger { + display: none; + background: none; + border: none; + color: #fff; + font-size: 22px; + cursor: pointer; + padding: 4px 8px; + margin-left: auto; + flex-shrink: 0; +} + +/* Mobile dropdown panel */ +.mobile-panel { + display: none; + position: absolute; + top: 52px; + left: 0; + right: 0; + background: var(--ink); + border-bottom: 2px solid var(--accent-bright); + padding: 12px 16px; + z-index: 50; + flex-direction: column; + gap: 6px; +} +.mobile-panel.open { display: flex; } +.mobile-panel a { + color: #8896a8; + text-decoration: none; + font-size: 14px; + font-family: var(--mono); + padding: 10px 14px; + border-radius: 4px; + letter-spacing: 0.5px; +} +.mobile-panel a:hover { color: #fff; background: rgba(255,255,255,0.08); } +.mobile-panel a.active { color: var(--accent-bright); background: rgba(0,170,255,0.08); } +.mobile-panel .mobile-auth { display: flex; gap: 8px; margin-top: 8px; padding-top: 8px; border-top: 1px solid rgba(255,255,255,0.1); } +.mobile-panel .mobile-auth .auth-btn { + font-family: var(--mono); font-size: 12px; padding: 6px 12px; + border: 1px solid rgba(255,255,255,0.2); background: transparent; color: #fff; + cursor: pointer; border-radius: 4px; white-space: nowrap; +} +.mobile-panel .mobile-auth .auth-btn:hover { background: rgba(255,255,255,0.1); } +.mobile-panel .mobile-auth .user-link { + color: var(--green-bright); font-family: var(--mono); font-size: 12px; text-decoration: none; white-space: nowrap; +} + +/* Main */ +.main { max-width: 920px; margin: 0 auto; padding: 24px 16px; } + +/* Tagline under header */ +.tagline { color: var(--ink-soft); font-size: 14px; margin-bottom: 20px; } + +/* Search */ +.search-bar { + display: flex; align-items: center; gap: 8px; + background: var(--surface); border: 1px solid var(--border-light); + border-radius: 8px; padding: 8px 14px; margin-bottom: 20px; +} +.search-bar .prompt { font-family: var(--mono); font-size: 13px; color: var(--green); flex-shrink: 0; } +.search-bar input { + border: none; outline: none; background: transparent; + font-family: var(--mono); font-size: 13px; color: var(--ink); flex: 1; min-width: 0; +} +.search-bar input::placeholder { color: var(--ink-dim); } + +/* Sort bar */ +.sort-bar { + display: flex; align-items: center; gap: 6px; margin-bottom: 16px; + font-family: var(--mono); font-size: 11px; color: var(--ink-dim); +} +.sort-label { letter-spacing: 0.5px; } +.sort-option { + padding: 3px 10px; border-radius: 3px; cursor: pointer; + border: 1px solid transparent; color: var(--ink-soft); transition: all 0.15s; +} +.sort-option:hover { border-color: var(--border-light); } +.sort-option.active { background: var(--panel); color: var(--ink); border-color: var(--border-light); } +.filter-toggle { + margin-left: auto; padding: 3px 10px; border-radius: 3px; cursor: pointer; + color: var(--ink-soft); border: 1px solid transparent; transition: all 0.15s; +} +.filter-toggle:hover { border-color: var(--border-light); } +.filter-toggle.active { background: var(--panel); color: var(--ink); border-color: var(--border-light); } + +/* Filter panel — collapsed by default */ +.filter-panel { display: none; margin-bottom: 16px; } +.filter-panel.open { display: block; } + +/* Filters */ +.filters { display: flex; gap: 6px; margin-bottom: 24px; flex-wrap: wrap; } +.filter-pill { + padding: 5px 12px; font-size: 11px; font-family: var(--mono); + background: var(--surface); border: 1px solid var(--border-light); + color: var(--ink-soft); cursor: pointer; transition: all 0.15s; + border-radius: 3px; letter-spacing: 0.5px; white-space: nowrap; +} +.filter-pill:hover { border-color: var(--accent); color: var(--accent); } +.filter-pill.active { background: var(--accent); color: #fff; border-color: var(--accent); } + +/* Protocol grid */ +.protocol-grid { display: flex; flex-direction: column; gap: 10px; } +.protocol-pill { + background: var(--surface); border: 1px solid var(--border-light); + border-radius: 16px; padding: 14px 20px; display: flex; align-items: flex-start; + gap: 14px; cursor: pointer; transition: all 0.15s; box-shadow: var(--shadow); +} +.protocol-pill:hover { border-color: var(--accent); box-shadow: var(--shadow-hover); transform: translateX(2px); } +.protocol-pill .icon { + width: 38px; height: 38px; border-radius: 10px; background: var(--panel); + display: flex; align-items: center; justify-content: center; + font-size: 18px; font-family: var(--mono); color: var(--accent); + flex-shrink: 0; border: 1px solid var(--border-light); +} +.protocol-pill .info { flex: 1; min-width: 0; } +.protocol-pill .info h3 { font-size: 14px; font-weight: bold; margin-bottom: 2px; font-family: var(--mono); word-break: break-word; } +.protocol-pill .info p { + font-size: 13px; color: var(--ink-soft); font-family: var(--sans); + word-break: break-word; line-height: 1.4; +} +.protocol-pill .meta { font-family: var(--mono); font-size: 11px; color: var(--ink-dim); text-align: right; flex-shrink: 0; white-space: nowrap; } +.protocol-pill .meta .steps { color: var(--accent); font-weight: bold; } +.protocol-pill.collection { + background: var(--ink); color: #fff; border-color: var(--accent-deep); +} +.protocol-pill.collection .icon { background: rgba(0,135,90,0.15); border-color: var(--green-bright); color: var(--green-bright); } +.protocol-pill.collection .info p { color: #8896a8; } +.protocol-pill.collection .meta { color: #8896a8; } +.protocol-pill.collection .meta .steps { color: var(--green-bright); } + +/* Detail view */ +.back-link { color: var(--accent); text-decoration: none; font-size: 12px; font-family: var(--mono); margin-bottom: 20px; display: inline-block; } +.back-link:hover { text-decoration: underline; } + +.detail-header { + background: var(--surface); border-radius: 16px; padding: 24px 20px; + margin-bottom: 16px; border: 1px solid var(--border-light); box-shadow: var(--shadow); +} +.detail-header .tag-row { display: flex; gap: 6px; margin-bottom: 12px; flex-wrap: wrap; } +.detail-header .tag { + font-family: var(--mono); font-size: 10px; padding: 3px 10px; border-radius: 3px; + background: var(--panel); color: var(--ink-soft); letter-spacing: 0.5px; +} +.detail-header h1 { + font-size: 22px; font-weight: bold; margin-bottom: 8px; letter-spacing: -0.3px; + font-family: var(--mono); word-break: break-word; +} +.detail-header .description { font-size: 15px; color: var(--ink-soft); margin-bottom: 16px; font-family: var(--sans); } +.detail-header .source { + font-family: var(--mono); font-size: 11px; color: var(--ink-dim); + border-top: 1px solid var(--border-light); padding-top: 12px; + word-break: break-all; +} +.detail-header .source a { color: var(--accent); text-decoration: none; } +.detail-header .fork-notice { + font-family: var(--mono); font-size: 11px; color: var(--green); + background: rgba(0,135,90,0.06); border: 1px solid rgba(0,135,90,0.15); + border-radius: 4px; padding: 6px 12px; margin-top: 12px; display: inline-block; +} + +.detail-section { + background: var(--surface); border-radius: 16px; padding: 20px; + margin-bottom: 12px; border: 1px solid var(--border-light); box-shadow: var(--shadow); +} +.detail-section h2 { + font-size: 12px; font-family: var(--mono); text-transform: uppercase; + letter-spacing: 1.5px; color: var(--accent); margin-bottom: 16px; +} +.detail-section h2::before { content: '// '; color: var(--ink-dim); } + +.step { display: flex; gap: 14px; margin-bottom: 14px; padding-bottom: 14px; border-bottom: 1px solid var(--border-light); } +.step:last-child { border-bottom: none; margin-bottom: 0; padding-bottom: 0; } +.step .number { font-family: var(--mono); font-weight: bold; font-size: 13px; color: var(--green); flex-shrink: 0; min-width: 32px; padding-top: 1px; } +.step .number::before { content: '> '; color: var(--ink-dim); } +.step .content h4 { font-size: 14px; font-weight: bold; margin-bottom: 3px; font-family: var(--mono); word-break: break-word; } +.step .content p { font-size: 13px; color: var(--ink-soft); font-family: var(--sans); word-break: break-word; } +.outcome, .practice { font-size: 14px; line-height: 1.6; font-family: var(--sans); } + +/* Buttons */ +.actions { display: flex; gap: 10px; margin-top: 24px; flex-wrap: wrap; } +.btn { + padding: 9px 18px; border-radius: 8px; font-family: var(--mono); font-size: 12px; + border: 1px solid var(--border); background: var(--surface); color: var(--ink); + cursor: pointer; transition: all 0.15s; letter-spacing: 0.5px; +} +.btn:hover { border-color: var(--accent); color: var(--accent); } +.btn.primary { background: var(--accent); color: #fff; border-color: var(--accent); } +.btn.primary:hover { background: var(--accent-deep); } +.btn.danger { border-color: #c0392b; color: #c0392b; } +.btn.danger:hover { background: #c0392b; color: #fff; } +.btn.add-step { margin: 12px 0; font-size: 12px; color: var(--green); border-color: var(--green); } +.btn.add-step:hover { background: var(--green); color: #fff; } + +/* Forms */ +.form-group { margin-bottom: 16px; } +.form-group label { display: block; font-family: var(--mono); font-size: 12px; color: var(--ink-soft); margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.5px; } +.form-group input, .form-group textarea, .form-group select { + width: 100%; padding: 10px 14px; border: 1px solid var(--border-light); border-radius: 8px; + font-family: var(--sans); font-size: 14px; color: var(--ink); background: var(--surface); +} +.form-group input:focus, .form-group textarea:focus, .form-group select:focus { + outline: none; border-color: var(--accent); box-shadow: 0 0 0 2px rgba(0,102,204,0.1); +} +.form-group textarea { resize: vertical; } + +/* Step editor */ +.step-editor { + background: var(--surface); border: 1px solid var(--border-light); + border-radius: 8px; padding: 16px; margin-bottom: 12px; +} +.step-editor .step-num { + font-family: var(--mono); font-size: 12px; color: var(--green); + margin-bottom: 10px; font-weight: bold; +} +.step-editor .step-field { margin-bottom: 10px; } +.step-editor .step-field label { + display: block; font-family: var(--mono); font-size: 11px; + color: var(--ink-soft); margin-bottom: 3px; text-transform: uppercase; letter-spacing: 0.5px; +} +.step-editor .step-field input { + width: 100%; padding: 8px 12px; border: 1px solid var(--border-light); + border-radius: 6px; font-family: var(--sans); font-size: 14px; color: var(--ink); + background: var(--surface); +} +.step-editor .step-field input:focus { + outline: none; border-color: var(--accent); box-shadow: 0 0 0 2px rgba(0,102,204,0.1); +} +.step-editor .remove-step { + font-size: 11px; color: #c0392b; cursor: pointer; background: none; + border: none; font-family: var(--mono); margin-top: 4px; +} +.step-editor .remove-step:hover { text-decoration: underline; } + +/* Modal */ +.modal-overlay { + position: fixed; top: 0; left: 0; right: 0; bottom: 0; + background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 200; + padding: 20px; +} +.modal { background: var(--surface); border-radius: 16px; padding: 28px; max-width: 400px; width: 100%; box-shadow: 0 8px 32px rgba(0,0,0,0.2); } +.modal h2 { font-family: var(--mono); font-size: 18px; margin-bottom: 20px; } +.auth-toggle { margin-top: 16px; font-size: 12px; font-family: var(--mono); color: var(--ink-soft); } +.auth-toggle a { color: var(--accent); cursor: pointer; text-decoration: none; } +.auth-toggle a:hover { text-decoration: underline; } + +/* Toast */ +.toast { + position: fixed; bottom: 70px; left: 50%; transform: translateX(-50%); + background: var(--ink); color: #fff; padding: 12px 24px; border-radius: 8px; + font-family: var(--mono); font-size: 13px; z-index: 300; + border: 1px solid var(--accent-bright); max-width: 90vw; text-align: center; + animation: toast-in 0.3s ease; +} +@keyframes toast-in { from { opacity: 0; transform: translateX(-50%) translateY(10px); } to { opacity: 1; } } + +/* Toolbar — search + create button on one line */ +.toolbar { + display: flex; align-items: center; gap: 10px; margin-bottom: 20px; +} +.toolbar .search-bar { flex: 1; margin-bottom: 0; } +.create-btn { + font-family: var(--mono); font-size: 13px; padding: 8px 16px; + border: 1px solid var(--accent); color: var(--accent); background: var(--surface); + border-radius: 8px; cursor: pointer; transition: all 0.15s; letter-spacing: 0.5px; + white-space: nowrap; flex-shrink: 0; +} +.create-btn:hover { background: var(--accent); color: #fff; } +.create-plus { display: none; } + +/* Profile */ +.profile-card { + background: var(--surface); border-radius: 16px; padding: 24px 20px; + margin-bottom: 16px; border: 1px solid var(--border-light); box-shadow: var(--shadow); +} +.profile-card h2 { font-family: var(--mono); font-size: 20px; margin-bottom: 8px; } +.profile-card .bio { font-size: 14px; color: var(--ink-soft); margin-bottom: 12px; } + +/* Collection detail — inverted header to match collection modules */ +#view-collection-detail .detail-header { + background: var(--ink); + color: #fff; + border: 2px solid var(--accent-deep); + box-shadow: 0 4px 12px rgba(0,50,100,0.12); +} +#view-collection-detail .detail-header h1 { color: var(--accent-bright); } +#view-collection-detail .detail-header .description { color: #a0aec0; } +#view-collection-detail .detail-header .source { border-top-color: rgba(255,255,255,0.1); color: #8896a8; } +#view-collection-detail .detail-header .source a { color: var(--accent-bright); } +#view-collection-detail .detail-section { + border: 2px solid var(--border); + box-shadow: 0 3px 10px rgba(0,50,100,0.08); +} +#view-collection-detail .detail-section h2 { color: var(--ink); } +#view-collection-detail .detail-section h2::before { content: '// '; color: var(--ink-dim); } + +/* Empty state */ +.empty { color: var(--ink-dim); font-family: var(--mono); font-size: 13px; padding: 20px; text-align: center; } +.empty a { color: var(--accent); text-decoration: none; } + +/* Tag input with chips */ +.tag-input-wrapper { position: relative; } +.tag-chips { + display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; + min-height: 0; +} +.tag-chips:empty { display: none; } +.tag-chip { + display: inline-flex; align-items: center; gap: 4px; + background: var(--panel); border: 1px solid var(--border-light); + border-radius: 4px; padding: 4px 10px; + font-family: var(--mono); font-size: 12px; color: var(--ink); +} +.tag-remove { + background: none; border: none; cursor: pointer; + font-size: 14px; color: var(--ink-dim); padding: 0; line-height: 1; +} +.tag-remove:hover { color: #c0392b; } +#tagInput { + width: 100%; padding: 8px 12px; border: 1px solid var(--border-light); + border-radius: 8px; font-family: var(--sans); font-size: 14px; + color: var(--ink); background: var(--surface); +} +#tagInput:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 2px rgba(0,102,204,0.1); } +.tag-suggestions { + display: none; position: absolute; top: 100%; left: 0; right: 0; + background: var(--surface); border: 1px solid var(--border-light); + border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); + z-index: 10; max-height: 200px; overflow-y: auto; +} +.tag-suggestion { + padding: 8px 14px; font-family: var(--mono); font-size: 13px; + cursor: pointer; color: var(--ink-soft); +} +.tag-suggestion:hover { background: var(--panel); color: var(--accent); } + +/* Tags on protocol pills */ +.protocol-pill .tag-row { display: flex; gap: 4px; flex-wrap: wrap; margin-top: 6px; } +.protocol-pill .tag { + font-family: var(--mono); font-size: 10px; padding: 2px 8px; + border-radius: 3px; background: var(--panel); color: var(--ink-dim); +} + +/* Selected protocols list in collection creator */ +.selected-list { margin-bottom: 16px; } +.selected-list:empty { display: none; } +.selected-list-header { + font-family: var(--mono); font-size: 12px; color: var(--green); + margin-bottom: 8px; font-weight: bold; +} +.selected-item { + display: flex; align-items: center; gap: 8px; + padding: 8px 12px; border: 1px solid var(--border-light); border-radius: 8px; + margin-bottom: 6px; background: var(--surface); transition: all 0.15s; +} +.selected-item:hover { border-color: var(--green); } +.selected-position { + font-family: var(--mono); font-size: 12px; color: var(--ink-dim); + flex-shrink: 0; min-width: 20px; text-align: center; +} +.selected-title { + flex: 1; min-width: 0; font-size: 13px; color: var(--ink); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.selected-move { + background: none; border: 1px solid var(--border-light); border-radius: 4px; + cursor: pointer; font-size: 10px; color: var(--ink-dim); padding: 2px 6px; line-height: 1; +} +.selected-move:hover { border-color: var(--accent); color: var(--accent); } +.selected-remove { + background: none; border: none; cursor: pointer; + font-size: 16px; color: var(--ink-dim); padding: 0 4px; line-height: 1; +} +.selected-remove:hover { color: #c0392b; } + +/* Protocol picker for collections — click to select, highlight when selected */ +.picker-pill { cursor: pointer; transition: all 0.15s; } +.picker-pill:hover { border-color: var(--accent); transform: none; } +.picker-pill.selected { border-color: var(--green); background: rgba(0,135,90,0.06); } +.picker-pill.selected:hover { border-color: var(--green-bright); } + +/* Mobile breakpoint */ +@media (max-width: 640px) { + /* Hide desktop nav and auth, show hamburger */ + header nav { display: none; } + .auth-area { display: none; } + .hamburger { display: block; } + + /* Create button shows just + on mobile */ + .create-text { display: none; } + .create-plus { display: inline; } + + .main { padding: 16px 12px; } + + /* Protocol pills: info takes full width, meta goes below */ + .protocol-pill { flex-direction: column; align-items: stretch; padding: 12px 14px; gap: 6px; } + .protocol-pill .info { width: 100%; } + .protocol-pill .meta { text-align: left; display: flex; gap: 12px; align-items: center; } + .protocol-pill .meta .steps { } + .protocol-pill .meta > div:not(.steps) { font-size: 10px; } + + .detail-header, .detail-section { padding: 16px 14px; border-radius: 12px; } +} \ No newline at end of file diff --git a/index.php b/index.php new file mode 100755 index 0000000..b02b08d --- /dev/null +++ b/index.php @@ -0,0 +1,67 @@ + 'text/css', + 'js' => 'application/javascript', + 'html' => 'text/html', + 'svg' => 'image/svg+xml', + 'png' => 'image/png', + 'jpg' => 'image/jpeg', + 'json' => 'application/json', +]; +if (isset($types[$ext])) header('Content-Type: ' . $types[$ext]); +readfile($real_file); \ No newline at end of file diff --git a/schema.sql b/schema.sql new file mode 100755 index 0000000..aa9f45a --- /dev/null +++ b/schema.sql @@ -0,0 +1,81 @@ +-- Protocol Droid — MySQL Schema +-- Works with MySQL 5.7+ / MariaDB 10.3+ + +CREATE TABLE IF NOT EXISTS users ( + id INT AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(32) UNIQUE NOT NULL, + display_name VARCHAR(128), + bio TEXT, + password_hash VARCHAR(255), -- bcrypt; NULL if SSO-only + sso_provider VARCHAR(64), -- 'oidc', 'github', 'google', etc. + sso_id VARCHAR(255), -- external identity id + role ENUM('admin','member','viewer') DEFAULT 'member', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS protocols ( + id INT AUTO_INCREMENT PRIMARY KEY, + slug VARCHAR(128) UNIQUE NOT NULL, + title VARCHAR(255) NOT NULL, + description TEXT, + source VARCHAR(255), -- free text or @username + source_url VARCHAR(500), + tags VARCHAR(500), -- JSON array string + forked_from VARCHAR(128), -- slug of parent protocol + image VARCHAR(500), + steps TEXT, -- JSON array of {headline, description} + outcome TEXT, + practice TEXT, + author_id INT REFERENCES users(id), + visibility ENUM('public','private','unlisted') DEFAULT 'public', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_visibility (visibility), + INDEX idx_author (author_id), + INDEX idx_forked (forked_from) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS collections ( + id INT AUTO_INCREMENT PRIMARY KEY, + slug VARCHAR(128) UNIQUE NOT NULL, + title VARCHAR(255) NOT NULL, + description TEXT, + source VARCHAR(255), + image VARCHAR(500), + author_id INT REFERENCES users(id), + visibility ENUM('public','private','unlisted') DEFAULT 'public', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_visibility (visibility), + INDEX idx_author (author_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS collection_items ( + collection_id INT NOT NULL REFERENCES collections(id) ON DELETE CASCADE, + item_type ENUM('protocol','collection') NOT NULL, + item_slug VARCHAR(128) NOT NULL, + position INT DEFAULT 0, + PRIMARY KEY (collection_id, item_type, item_slug) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS votes ( + user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + protocol_slug VARCHAR(128) NOT NULL, + value INT DEFAULT 1, -- +1 upvote, -1 downvote + comment TEXT, -- optional evaluation text + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, protocol_slug) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS config ( + `key` VARCHAR(64) PRIMARY KEY, + `value` TEXT +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Default config entries +INSERT INTO config (`key`, `value`) VALUES + ('site_name', 'Protocol Droid'), + ('site_tagline', 'A commons of social protocols'), + ('registration_enabled', 'true'), + ('require_approval', 'false') +ON DUPLICATE KEY UPDATE `value` = VALUES(`value`); \ No newline at end of file diff --git a/seeds/appreciative-apology.yaml b/seeds/appreciative-apology.yaml new file mode 100755 index 0000000..0cb8b6e --- /dev/null +++ b/seeds/appreciative-apology.yaml @@ -0,0 +1,33 @@ +id: appreciative-apology +title: Appreciative Apology +description: >- + A protocol for repairing harm through structured acknowledgment, moving + beyond reflexive apologies toward genuine accountability. +source: Restorative Justice Project +source_url: https://restorativejustice.org/ +tags: [conflict] +forked_from: null +image: null +steps: + - headline: Name the harm + description: >- + The person apologizing specifically describes what they did, without + minimization or justification. + - headline: Acknowledge the impact + description: >- + They describe the impact they understand their action had on the + other person or group. + - headline: Express commitment + description: >- + They state what they will do differently, concretely. No future + promises without specific actions. + - headline: Invite response + description: >- + They ask the other person if there is anything they have missed, and + listen without defending. +outcome: >- + The harmed party feels heard and acknowledged. The relationship has a + path forward with concrete commitments. +practice: >- + Avoid the word "but" — it negates everything before it. "I'm sorry, but..." + is not an apology. Practice pausing after each step. \ No newline at end of file diff --git a/seeds/consent-decision-making.yaml b/seeds/consent-decision-making.yaml new file mode 100755 index 0000000..77a6082 --- /dev/null +++ b/seeds/consent-decision-making.yaml @@ -0,0 +1,38 @@ +id: consent-decision-making +title: Consent Decision-Making +description: >- + A process for making decisions that no one objects to, enabling rapid + group decisions without requiring full agreement. +source: Sociocracy for All +source_url: https://www.sociocracyforall.org/ +tags: [decision, facilitation] +forked_from: null +image: null +steps: + - headline: Present the proposal + description: >- + The proposer reads the proposal aloud. Clarifying questions are + allowed, but no discussion yet. + - headline: Round of reactions + description: >- + Each person briefly shares their reaction. No cross-discussion — + just hearing all voices. + - headline: Amend the proposal + description: >- + The proposer may amend based on reactions. The group can suggest + changes by consent. + - headline: Test for consent + description: >- + The facilitator asks: "Does anyone have a reasoned, paramount + objection to this proposal?" + - headline: Declare the decision + description: >- + If no objections remain, the decision is adopted. Objections are + integrated and the process repeats from step 3. +outcome: >- + A decision is made that no one has a paramount objection to. The group + moves forward without blocking minority voices. +practice: >- + A "reasoned, paramount objection" is not a preference — it must explain + why the proposal would cause harm. Practice distinguishing preferences + from objections. \ No newline at end of file diff --git a/seeds/dot-voting.yaml b/seeds/dot-voting.yaml new file mode 100755 index 0000000..1d8f158 --- /dev/null +++ b/seeds/dot-voting.yaml @@ -0,0 +1,34 @@ +id: dot-voting +title: Dot Voting +description: >- + A quick visual method for a group to prioritize options together using + adhesive dots on a shared display. +source: Group Works Deck +source_url: https://groupworksdeck.org/deck +tags: [decision, facilitation] +forked_from: null +image: null +steps: + - headline: Post the options + description: >- + All options are written on a shared wall or board, spaced apart for + easy voting. + - headline: Distribute dots + description: >- + Each person receives a fixed number of adhesive dots (typically 3-5). + They may place multiple dots on one option. + - headline: Vote silently + description: >- + Everyone places their dots simultaneously. No discussion during + voting. + - headline: Review results + description: >- + The group reviews the dot distribution together. High-vote items + become priorities, but the pattern matters more than the count. +outcome: >- + The group has a visual map of collective priorities. Everyone + participated equally, and the result is transparent. +practice: >- + Use different colored dots for different categories of voter (e.g., + staff vs. volunteers) to see patterns. Photograph the result before + taking it down. \ No newline at end of file diff --git a/seeds/fishbowl-discussion.yaml b/seeds/fishbowl-discussion.yaml new file mode 100755 index 0000000..1516430 --- /dev/null +++ b/seeds/fishbowl-discussion.yaml @@ -0,0 +1,36 @@ +id: fishbowl-discussion +title: Fishbowl Discussion +description: >- + A format for productive conversation in large groups, where a small + inner circle discusses while an outer circle observes. +source: Group Works Deck +source_url: https://groupworksdeck.org/deck +tags: [facilitation] +forked_from: null +image: null +steps: + - headline: Set up the circles + description: >- + Arrange 4-6 chairs in an inner circle for discussants. The rest of + the group sits in an outer circle around them. + - headline: Begin the discussion + description: >- + The inner circle discusses the topic. Only people in the inner + circle may speak. + - headline: Rotate seats + description: >- + One chair is left empty. Anyone from the outer circle can tap a + discussant and take their seat. The displaced person joins the + outer circle. + - headline: Debrief together + description: >- + After the discussion, the full group reflects on what was said and + what patterns they noticed in the conversation. +outcome: >- + A focused, in-depth discussion happens without the chaos of a + full-group free-for-all. More voices contribute than in a panel + format. +practice: >- + Set a minimum time before rotation (2-3 minutes) so discussants can + make a point before being tapped. Brief the outer circle to listen + for themes. \ No newline at end of file diff --git a/seeds/round-robin-check-in.yaml b/seeds/round-robin-check-in.yaml new file mode 100755 index 0000000..24cefb3 --- /dev/null +++ b/seeds/round-robin-check-in.yaml @@ -0,0 +1,30 @@ +id: round-robin-check-in +title: Round Robin Check-In +description: >- + A structured way for each person in a group to share briefly, + ensuring everyone is heard before discussion begins. +source: Group Works Deck +source_url: https://groupworksdeck.org/deck +tags: [check-in, facilitation] +forked_from: null +image: null +steps: + - headline: Frame the round + description: >- + The facilitator explains that each person will have up to one minute + to share, with no cross-talk or responses. + - headline: Go around + description: >- + Each person speaks in turn, proceeding around the circle. Passing is + always an option. + - headline: Note themes + description: >- + The facilitator notes recurring themes without commenting. These + become the basis for the discussion that follows. +outcome: >- + Everyone has been heard; the group has a shared sense of where things + stand and what themes deserve further discussion. +practice: >- + Use a talking object to signal whose turn it is. Keep time gently — a + soft chime works better than a timer. If the group is large (12+), + consider breaking into smaller rounds. \ No newline at end of file diff --git a/seeds/temperature-reading.yaml b/seeds/temperature-reading.yaml new file mode 100755 index 0000000..2d73310 --- /dev/null +++ b/seeds/temperature-reading.yaml @@ -0,0 +1,37 @@ +id: temperature-reading +title: Temperature Reading +description: >- + A regular practice for surfacing group dynamics before they escalate, + developed by Diana Larsen at the Software Testing Support Center. +source: STSC / Diana Larsen +source_url: https://www.dianalarsen.com/ +tags: [check-in, conflict] +forked_from: null +image: null +steps: + - headline: Appreciations + description: >- + Each person shares something they appreciate about another group + member or the group as a whole. + - headline: New information + description: >- + Each person shares any new information relevant to the group that + others may not know. + - headline: Puzzles + description: >- + Each person shares something they are confused or wondering about. + No responses needed yet. + - headline: Complaints with advice + description: >- + Each person may share a complaint — but must pair it with a + suggestion for improvement. + - headline: Wishes, hopes, and dreams + description: >- + Each person shares something they hope for the group's future. + End on an aspirational note. +outcome: >- + The group has a current snapshot of its emotional and informational + state. Unspoken tensions have a structured outlet. +practice: >- + Do this regularly (weekly or biweekly) so it becomes routine. Time-box + each section to 5 minutes. Model vulnerability as a facilitator. \ No newline at end of file