commit eaaadd39e4528a891ffdf5769919b8e54d8d9691 Author: RaineAllDay Date: Wed Mar 18 03:06:27 2026 -0600 initial deployment v1.0 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8a0189b --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +DATABASE_URL=/var/lib/etc-prs/personalities.db +RATE_LIMIT_PUBLISH=5 +RATE_LIMIT_READ=100 +PUBLIC_BASE_URL=https://yourdomain.com + +# Admin +# No value needed here — admins are created via: node scripts/create-admin.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..28b0a04 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.env +*.db +*.db-shm +*.db-wal +node_modules/ +build/ +.svelte-kit/ +.DS_Store diff --git a/BINARY_LAYOUT.md b/BINARY_LAYOUT.md new file mode 100644 index 0000000..ff6ef02 --- /dev/null +++ b/BINARY_LAYOUT.md @@ -0,0 +1,41 @@ +# PRS Binary Layout + +## File Structure + +| Offset | Size | Description | +|-------|------|------------| +| 0x0000 | 4 | Reserved (always 0x00000000) | +| 0x0004 | 0x218 | Personality Struct | + +--- + +## Personality Struct + +| Offset | Size | Description | +|-------|------|------------| +| 0x0D | 1 | Channel Count | +| 0x0E | 12 | Fixture Name (null-terminated) | +| 0x1C | ... | Channel Blocks | + +--- + +## Channel Block (8 bytes) + +| Offset | Size | Description | +|-------|------|------------| +| +0x00 | 1 | Flags | +| +0x01 | 1 | Reserved | +| +0x02 | 1 | Reserved | +| +0x03 | 1 | Reserved | +| +0x04 | 1 | Attribute ID | +| +0x05 | 1 | Home Value | +| +0x06 | 1 | Display Format | +| +0x07 | 1 | Channel Index (0-based) | + +--- + +## Notes + +- Max 64 channels +- 16-bit channels use flag bit +- Home value for 16-bit pairs stored in second channel diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 0000000..8a796c2 --- /dev/null +++ b/DEPLOY.md @@ -0,0 +1,372 @@ +# Deployment Guide — ETC PRS Viewer & Editor + +This guide covers deploying the app to a **Digital Ocean Droplet** running Ubuntu 24.04. + +--- + +## Prerequisites + +- A Digital Ocean Droplet (1 GB RAM minimum, 2 GB recommended) +- A domain name pointed at your droplet's IP +- SSH access to the droplet + +--- + +## 1. Initial Server Setup + +```bash +# Update packages +sudo apt update && sudo apt upgrade -y + +# Install Node.js 20 (LTS) +curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - +sudo apt install -y nodejs + +# Verify +node --version # should be v20.x +npm --version + +# Install build tools (needed for better-sqlite3) +sudo apt install -y build-essential python3 + +# Install PM2 globally +sudo npm install -g pm2 + +# Install Nginx +sudo apt install -y nginx + +# Install Certbot for SSL +sudo apt install -y certbot python3-certbot-nginx +``` + +--- + +## 2. Create the App User and Directories + +```bash +# Create a dedicated user for the app (no login shell) +sudo useradd --system --shell /bin/false --home /opt/etc-prs prs + +# Create app directory +sudo mkdir -p /opt/etc-prs/app +sudo chown -R prs:prs /opt/etc-prs + +# Create data directory for SQLite (outside the app, survives redeployment) +sudo mkdir -p /var/lib/etc-prs +sudo chown -R prs:prs /var/lib/etc-prs + +# Create log directory +sudo mkdir -p /var/log/etc-prs +sudo chown -R prs:prs /var/log/etc-prs +``` + +--- + +## 3. Deploy the Application + +```bash +# Clone or copy your project to the server +# Option A: Git +cd /opt/etc-prs +sudo -u prs git clone https://github.com/yourusername/etc-prs-ui.git app + +# Option B: Copy files via scp from your local machine +# scp -r ./etc-prs-ui user@your-droplet-ip:/tmp/ +# sudo mv /tmp/etc-prs-ui /opt/etc-prs/app +# sudo chown -R prs:prs /opt/etc-prs/app + +cd /opt/etc-prs/app + +# Install dependencies (as the prs user) +sudo -u prs npm install + +# Build the app +sudo -u prs npm run build +``` + +--- + +## 4. Environment Configuration + +```bash +# Create the production .env file +sudo -u prs nano /opt/etc-prs/app/.env +``` + +Add the following contents: + +```env +DATABASE_URL=/var/lib/etc-prs/personalities.db +RATE_LIMIT_PUBLISH=5 +RATE_LIMIT_READ=100 +PUBLIC_BASE_URL=https://yourdomain.com +``` + +Replace `yourdomain.com` with your actual domain. + +--- + +## 5. Configure PM2 + +Create the PM2 ecosystem file: + +```bash +sudo -u prs nano /opt/etc-prs/app/ecosystem.config.cjs +``` + +```javascript +module.exports = { + apps: [{ + name: 'etc-prs', + script: '/opt/etc-prs/app/build/index.js', + cwd: '/opt/etc-prs/app', + user: 'prs', + env: { + NODE_ENV: 'production', + PORT: '3000', + HOST: '127.0.0.1', + }, + error_file: '/var/log/etc-prs/error.log', + out_file: '/var/log/etc-prs/out.log', + log_date_format: 'YYYY-MM-DD HH:mm:ss', + restart_delay: 3000, + max_restarts: 10, + }] +}; +``` + +Start the app with PM2: + +```bash +# Start +sudo -u prs pm2 start /opt/etc-prs/app/ecosystem.config.cjs + +# Save the process list so PM2 restores it on reboot +sudo -u prs pm2 save + +# Set PM2 to start on system boot +sudo pm2 startup systemd -u prs --hp /opt/etc-prs +# Run the command PM2 outputs +``` + +Verify the app is running: + +```bash +sudo -u prs pm2 status +# Should show etc-prs as "online" + +# Check logs +sudo -u prs pm2 logs etc-prs --lines 50 +``` + +--- + +## 6. Configure Nginx + +```bash +sudo nano /etc/nginx/sites-available/etc-prs +``` + +```nginx +server { + listen 80; + server_name yourdomain.com www.yourdomain.com; + + # Gzip + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml; + + # Security headers + add_header X-Frame-Options "SAMEORIGIN"; + add_header X-Content-Type-Options "nosniff"; + add_header Referrer-Policy "strict-origin-when-cross-origin"; + + # Proxy to SvelteKit + location / { + proxy_pass http://127.0.0.1:3000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + + # Increase timeout for large uploads + proxy_read_timeout 30s; + client_max_body_size 2M; + } +} +``` + +Enable and test: + +```bash +sudo ln -s /etc/nginx/sites-available/etc-prs /etc/nginx/sites-enabled/ +sudo nginx -t # should say "syntax is ok" +sudo systemctl reload nginx +``` + +--- + +## 7. SSL with Let's Encrypt + +```bash +sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com +``` + +Follow the prompts. Certbot will automatically modify your Nginx config to add HTTPS and set up auto-renewal. + +Verify auto-renewal works: + +```bash +sudo certbot renew --dry-run +``` + +--- + +## 8. Database Backups + +Set up a nightly cron job to back up the SQLite database: + +```bash +sudo mkdir -p /var/backups/etc-prs + +# Create backup script +sudo nano /opt/etc-prs/backup.sh +``` + +```bash +#!/bin/bash +DATE=$(date +%Y-%m-%d) +BACKUP_DIR=/var/backups/etc-prs +DB_PATH=/var/lib/etc-prs/personalities.db + +# Create backup using SQLite's online backup +sqlite3 "$DB_PATH" ".backup $BACKUP_DIR/personalities-$DATE.db" + +# Keep only the last 30 days of backups +find "$BACKUP_DIR" -name "personalities-*.db" -mtime +30 -delete + +echo "Backup completed: personalities-$DATE.db" +``` + +```bash +sudo chmod +x /opt/etc-prs/backup.sh + +# Add to cron (runs at 2am daily) +sudo crontab -e +``` + +Add this line: + +```cron +0 2 * * * /opt/etc-prs/backup.sh >> /var/log/etc-prs/backup.log 2>&1 +``` + +--- + +## 9. Redeployment + +When you push updates: + +```bash +cd /opt/etc-prs/app + +# Pull latest code +sudo -u prs git pull + +# Install any new dependencies +sudo -u prs npm install + +# Rebuild +sudo -u prs npm run build + +# Restart the app (zero-downtime reload) +sudo -u prs pm2 reload etc-prs +``` + +The SQLite database at `/var/lib/etc-prs/personalities.db` is **never touched** by redeployment since it lives outside the app directory. + +--- + +## 10. Useful Commands + +```bash +# View live logs +sudo -u prs pm2 logs etc-prs + +# Restart app +sudo -u prs pm2 restart etc-prs + +# Stop app +sudo -u prs pm2 stop etc-prs + +# Check Nginx status +sudo systemctl status nginx + +# View Nginx error log +sudo tail -f /var/log/nginx/error.log + +# Open SQLite database directly +sqlite3 /var/lib/etc-prs/personalities.db + +# Example queries +sqlite3 /var/lib/etc-prs/personalities.db "SELECT id, name, manufacturer, channel_count, created_at FROM personalities ORDER BY created_at DESC LIMIT 20;" +sqlite3 /var/lib/etc-prs/personalities.db "SELECT COUNT(*) FROM personalities;" + +# Delete a personality by ID (if you need to moderate) +sqlite3 /var/lib/etc-prs/personalities.db "DELETE FROM personalities WHERE id = 'the_id_here';" +``` + +--- + +## 11. Firewall + +```bash +# Allow SSH, HTTP, and HTTPS +sudo ufw allow OpenSSH +sudo ufw allow 'Nginx Full' +sudo ufw enable +sudo ufw status +``` + +--- + +## 12. Creating Admin Users + +Admins are managed via a CLI script — there is no self-registration UI. + +```bash +# Create your first admin (run from the app directory on the server) +cd /opt/etc-prs/app +node scripts/create-admin.js your-username your-password + +# To update an existing admin's password (same command — it upserts) +node scripts/create-admin.js your-username new-password + +# To add another admin +node scripts/create-admin.js another-user their-password +``` + +Requirements: +- Username: 2–32 characters +- Password: minimum 8 characters (use something strong) + +The admin panel is available at `https://yourdomain.com/admin`. + +--- + +## Local Development + +```bash +# Install dependencies +npm install + +# Start dev server (uses ./dev.db automatically) +npm run dev + +# The app runs at http://localhost:5173 +# SQLite database is created at ./dev.db on first run +``` diff --git a/README.md b/README.md new file mode 100644 index 0000000..983c8fb --- /dev/null +++ b/README.md @@ -0,0 +1,124 @@ +# ETC PRS Viewer & Editor (Web-Based) + +A modern, web-based viewer and editor for ETC Expression .prs personality files. +This tool allows you to inspect, modify, and export fixture personalities using an intuitive UI with support for 16-bit channel pairing, drag-and-drop reordering, and local file history. + +# 🚀 Application Functionality + +## Core Features +- Open & View PRS Files + - Load .prs files directly from your system + - Clean visual layout with card or table views + - Accurate parsing based on reverse-engineered binary structure +- Edit Personalities + - Modify channel attributes, flags, and home values + - Support for: + - Independent + - LTP + - 16-bit + - Flipped + - 16-bit channels are represented as a single unified card +- 16-bit Channel Handling + - Proper pairing based on actual binary flags (no heuristics) + - Editing applies correctly across paired channels + - Home/display values follow ETC’s storage behavior (stored in second channel) +- Drag-and-Drop Reordering + - Reorder channels using a dedicated drag handle + - 16-bit pairs move together as a single unit + - Prevents accidental dragging while editing inputs +- Export PRS Files + - Export valid .prs files from both: + - Viewer page + - Editor page + - Output matches ETC Personality Editor structure +## UI Features +- Global Menu Bar + - Open PRS File (auto-load on selection) + - Create New PRS File + - Previously Opened Files + - Snapshot Versions +- Secondary Control Bar (Contextual) + - Viewer: + - Switch to Editor + - Toggle Card/Table view + - Export PRS + - Editor: + - Add Channel + - Save Snapshot + - Export PRS + - Exit Editor + - Toggle Card/Table view +- View Modes + - Card-based layout for intuitive editing + - Table view for compact overview + +## Data Persistence +Uses browser localStorage for: +Recently opened files +- Snapshot history +- No backend required + +# 🛠️ How to Run the Program +Requirements: +- Node.js (v18+ recommended) +- npm + +## Setup +### Install dependencies +```sh +npm install +``` +### Run Development Server + +```sh +npm run dev +``` + +Then open your browser to: +http://localhost:5173 + +## Build for Production +```sh +npm run build +npm run preview +``` +## ⚠️ Ethical Disclosure + +This project includes the use of AI-assisted development and reverse engineering techniques. + +## AI Usage + +Portions of this application—including: +- UI implementation +- Data handling logic +- Iterative refinement of features + +were developed with the assistance of an AI system (ChatGPT). +All outputs were reviewed, tested, and refined by a human developer before inclusion. + +## Reverse Engineering + +The .prs file format used by ETC Expression lighting consoles is not publicly documented in full detail. +To support this application: + +- The file format was analyzed through: + - Inspection of real .prs files + - Behavioral comparison with ETC’s Personality Editor + - Static analysis of the Personality Editor executable +- No proprietary source code was accessed or used. +- The reverse engineering was limited to understanding file structure for interoperability. + +## Intent + +This project is intended for: +- Educational purposes +- Interoperability and tooling +- Supporting legacy systems + +It is not affiliated with or endorsed by ETC (Electronic Theatre Controls). + +# 📌 Notes + +- All file handling is local to your machine/browser +- No data is transmitted externally +- The application is designed to faithfully replicate ETC behavior where possible \ No newline at end of file diff --git a/etc-prs-ui-gpt.zip b/etc-prs-ui-gpt.zip new file mode 100644 index 0000000..5c3713d Binary files /dev/null and b/etc-prs-ui-gpt.zip differ diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..c4a5f5b --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2963 @@ +{ + "name": "etc-prs-ui", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "etc-prs-ui", + "version": "1.0.0", + "dependencies": { + "bcryptjs": "^2.4.3", + "better-sqlite3": "^11.3.0", + "dotenv": "^17.3.1", + "lucide-svelte": "^0.454.0", + "nanoid": "^5.0.7", + "slugify": "^1.6.6" + }, + "devDependencies": { + "@sveltejs/adapter-node": "^5.2.0", + "@sveltejs/kit": "^2.5.18", + "@sveltejs/vite-plugin-svelte": "^3.1.2", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "svelte": "^4.2.19", + "tailwindcss": "^3.4.17", + "vite": "^5.4.10" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true + }, + "node_modules/@rollup/plugin-commonjs": { + "version": "29.0.2", + "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-29.0.2.tgz", + "integrity": "sha512-S/ggWH1LU7jTyi9DxZOKyxpVd4hF/OZ0JrEbeLjXk/DFXwRny0tjD2c992zOUYQobLrVkRVMDdmHP16HKP7GRg==", + "dev": true, + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "commondir": "^1.0.1", + "estree-walker": "^2.0.2", + "fdir": "^6.2.0", + "is-reference": "1.2.1", + "magic-string": "^0.30.3", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=16.0.0 || 14 >= 14.17" + }, + "peerDependencies": { + "rollup": "^2.68.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-commonjs/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true + }, + "node_modules/@rollup/plugin-commonjs/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-commonjs/node_modules/is-reference": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", + "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", + "dev": true, + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@rollup/plugin-commonjs/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@rollup/plugin-json": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz", + "integrity": "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==", + "dev": true, + "dependencies": { + "@rollup/pluginutils": "^5.1.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-node-resolve": { + "version": "16.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz", + "integrity": "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==", + "dev": true, + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "@types/resolve": "1.20.2", + "deepmerge": "^4.2.2", + "is-module": "^1.0.0", + "resolve": "^1.22.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.78.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "dev": true, + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true + }, + "node_modules/@rollup/pluginutils/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz", + "integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==", + "dev": true, + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/adapter-node": { + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-node/-/adapter-node-5.5.4.tgz", + "integrity": "sha512-45X92CXW+2J8ZUzPv3eLlKWEzINKiiGeFWTjyER4ZN4sGgNoaoeSkCY/QYNxHpPXy71QPsctwccBo9jJs0ySPQ==", + "dev": true, + "dependencies": { + "@rollup/plugin-commonjs": "^29.0.0", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^16.0.0", + "rollup": "^4.59.0" + }, + "peerDependencies": { + "@sveltejs/kit": "^2.4.0" + } + }, + "node_modules/@sveltejs/kit": { + "version": "2.55.0", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.55.0.tgz", + "integrity": "sha512-MdFRjevVxmAknf2NbaUkDF16jSIzXMWd4Nfah0Qp8TtQVoSp3bV4jKt8mX7z7qTUTWvgSaxtR0EG5WJf53gcuA==", + "dev": true, + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/cookie": "^0.6.0", + "acorn": "^8.14.1", + "cookie": "^0.6.0", + "devalue": "^5.6.4", + "esm-env": "^1.2.2", + "kleur": "^4.1.5", + "magic-string": "^0.30.5", + "mrmime": "^2.0.0", + "set-cookie-parser": "^3.0.0", + "sirv": "^3.0.0" + }, + "bin": { + "svelte-kit": "svelte-kit.js" + }, + "engines": { + "node": ">=18.13" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.3.3", + "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-3.1.2.tgz", + "integrity": "sha512-Txsm1tJvtiYeLUVRNqxZGKR/mI+CzuIQuc2gn+YCs9rMTowpNZ2Nqt53JdL8KF9bLhAf2ruR/dr9eZCwdTriRA==", + "dev": true, + "dependencies": { + "@sveltejs/vite-plugin-svelte-inspector": "^2.1.0", + "debug": "^4.3.4", + "deepmerge": "^4.3.1", + "kleur": "^4.1.5", + "magic-string": "^0.30.10", + "svelte-hmr": "^0.16.0", + "vitefu": "^0.2.5" + }, + "engines": { + "node": "^18.0.0 || >=20" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "vite": "^5.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte-inspector": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-2.1.0.tgz", + "integrity": "sha512-9QX28IymvBlSCqsCll5t0kQVxipsfhFFL+L2t3nTWfXnddYwxBuAEtTtlaVQpRz9c37BhJjltSeY4AJSC03SSg==", + "dev": true, + "dependencies": { + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.0.0 || >=20" + }, + "peerDependencies": { + "@sveltejs/vite-plugin-svelte": "^3.0.0", + "svelte": "^4.0.0 || ^5.0.0-next.0", + "vite": "^5.0.0" + } + }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "dev": true + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==" + }, + "node_modules/@types/resolve": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", + "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", + "dev": true + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/autoprefixer": { + "version": "10.4.27", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz", + "integrity": "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001774", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.8", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.8.tgz", + "integrity": "sha512-PCLz/LXGBsNTErbtB6i5u4eLpHeMfi93aUv5duMmj6caNu6IphS4q6UevDnL36sZQv9lrP11dbPKGMaXPwMKfQ==", + "dev": true, + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==" + }, + "node_modules/better-sqlite3": { + "version": "11.10.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", + "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==", + "hasInstallScript": true, + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001780", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001780.tgz", + "integrity": "sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==" + }, + "node_modules/code-red": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/code-red/-/code-red-1.0.4.tgz", + "integrity": "sha512-7qJWqItLA8/VPVlKJlFXU+NBlo/qyfs39aJcuMT/2ere32ZqvF5OSxgdM5xOfJJ7O429gg2HM47y8v9P+9wrNw==", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15", + "@types/estree": "^1.0.1", + "acorn": "^8.10.0", + "estree-walker": "^3.0.3", + "periscopic": "^3.1.0" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "dev": true + }, + "node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/css-tree": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz", + "integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==", + "dependencies": { + "mdn-data": "2.0.30", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.6.4", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz", + "integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==", + "dev": true + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true + }, + "node_modules/dotenv": { + "version": "17.3.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", + "integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.313", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.313.tgz", + "integrity": "sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA==", + "dev": true + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==" + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==" + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", + "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", + "dev": true + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==" + }, + "node_modules/lucide-svelte": { + "version": "0.454.0", + "resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-0.454.0.tgz", + "integrity": "sha512-TgW17HI7M8LeFZ3NpaDp1LwPGBGMVjx/x81TtK+AacEQvmJcqetqeJNeBT18NMEJP9+zi/Wt+Zc8mo44K5Uszw==", + "peerDependencies": { + "svelte": "^3 || ^4 || ^5.0.0-next.42" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mdn-data": { + "version": "2.0.30", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz", + "integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==" + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "5.1.7", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.7.tgz", + "integrity": "sha512-ua3NDgISf6jdwezAheMOk4mbE1LXjm1DfMUDMuJf4AqxLFK3ccGpgWizwa5YV7Yz9EpXwEaWoRXSb/BnV0t5dQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==" + }, + "node_modules/node-abi": { + "version": "3.89.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz", + "integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "dev": true + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "node_modules/periscopic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/periscopic/-/periscopic-3.1.0.tgz", + "integrity": "sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw==", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^3.0.0", + "is-reference": "^3.0.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true + }, + "node_modules/postcss/node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-cookie-parser": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.0.1.tgz", + "integrity": "sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q==", + "dev": true + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/slugify": { + "version": "1.6.8", + "resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.8.tgz", + "integrity": "sha512-HVk9X1E0gz3mSpoi60h/saazLKXKaZThMLU3u/aNwoYn8/xQyX2MGxL0ui2eaokkD7tF+Zo+cKTHUbe1mmmGzA==", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/svelte": { + "version": "4.2.20", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-4.2.20.tgz", + "integrity": "sha512-eeEgGc2DtiUil5ANdtd8vPwt9AgaMdnuUFnPft9F5oMvU/FHu5IHFic+p1dR/UOB7XU2mX2yHW+NcTch4DCh5Q==", + "dependencies": { + "@ampproject/remapping": "^2.2.1", + "@jridgewell/sourcemap-codec": "^1.4.15", + "@jridgewell/trace-mapping": "^0.3.18", + "@types/estree": "^1.0.1", + "acorn": "^8.9.0", + "aria-query": "^5.3.0", + "axobject-query": "^4.0.0", + "code-red": "^1.0.3", + "css-tree": "^2.3.1", + "estree-walker": "^3.0.3", + "is-reference": "^3.0.1", + "locate-character": "^3.0.0", + "magic-string": "^0.30.4", + "periscopic": "^3.1.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/svelte-hmr": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/svelte-hmr/-/svelte-hmr-0.16.0.tgz", + "integrity": "sha512-Gyc7cOS3VJzLlfj7wKS0ZnzDVdv3Pn2IuVeJPk9m2skfhcu5bq3wtIZyQGggr7/Iim5rH5cncyQft/kRLupcnA==", + "dev": true, + "engines": { + "node": "^12.20 || ^14.13.1 || >= 16" + }, + "peerDependencies": { + "svelte": "^3.19.0 || ^4.0.0" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-0.2.5.tgz", + "integrity": "sha512-SgHtMLoqaeeGnd2evZ849ZbACbnwQCIwRH57t18FxcXoZop0uQu0uzlIhJBlF/eWVzuce0sHeqPcDo+evVcg8Q==", + "dev": true, + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..7100feb --- /dev/null +++ b/package.json @@ -0,0 +1,29 @@ +{ + "name": "etc-prs-ui", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "start": "node build" + }, + "devDependencies": { + "@sveltejs/adapter-node": "^5.2.0", + "@sveltejs/kit": "^2.5.18", + "@sveltejs/vite-plugin-svelte": "^3.1.2", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "svelte": "^4.2.19", + "tailwindcss": "^3.4.17", + "vite": "^5.4.10" + }, + "dependencies": { + "bcryptjs": "^2.4.3", + "better-sqlite3": "^11.3.0", + "lucide-svelte": "^0.454.0", + "nanoid": "^5.0.7", + "slugify": "^1.6.6" + } +} diff --git a/postcss.config.cjs b/postcss.config.cjs new file mode 100644 index 0000000..5cbc2c7 --- /dev/null +++ b/postcss.config.cjs @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {} + } +}; diff --git a/scripts/create-admin.js b/scripts/create-admin.js new file mode 100644 index 0000000..9f31d01 --- /dev/null +++ b/scripts/create-admin.js @@ -0,0 +1,72 @@ +#!/usr/bin/env node +/** + * Create or update an admin user. + * + * Usage: + * node scripts/create-admin.js + * + * If the username already exists, the password is updated (upsert). + * Run this directly on the server after deployment. + * + * Example: + * node scripts/create-admin.js raine supersecretpassword + */ + +import bcrypt from 'bcryptjs'; +import Database from 'better-sqlite3'; +import { randomBytes } from 'crypto'; +import { config } from 'dotenv'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; + +// Load .env from project root +const __dirname = dirname(fileURLToPath(import.meta.url)); +config({ path: join(__dirname, '..', '.env') }); + +const [,, username, password] = process.argv; + +if (!username || !password) { + console.error('Usage: node scripts/create-admin.js '); + process.exit(1); +} + +if (username.length < 2 || username.length > 32) { + console.error('Username must be between 2 and 32 characters.'); + process.exit(1); +} + +if (password.length < 8) { + console.error('Password must be at least 8 characters.'); + process.exit(1); +} + +const dbPath = process.env.DATABASE_URL ?? './dev.db'; +const db = new Database(dbPath); +db.pragma('journal_mode = WAL'); +db.pragma('foreign_keys = ON'); + +// Ensure the admins table exists (safe to run before full app init) +db.exec(` + CREATE TABLE IF NOT EXISTS admins ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + created_at TEXT NOT NULL + ); +`); + +const existing = db.prepare(`SELECT id FROM admins WHERE username = ?`).get(username); +const hash = bcrypt.hashSync(password, 12); +const now = new Date().toISOString(); + +if (existing) { + db.prepare(`UPDATE admins SET password_hash = ? WHERE username = ?`).run(hash, username); + console.log(`✓ Password updated for admin: ${username}`); +} else { + const id = randomBytes(8).toString('hex'); + db.prepare(`INSERT INTO admins (id, username, password_hash, created_at) VALUES (?, ?, ?, ?)`) + .run(id, username, hash, now); + console.log(`✓ Admin created: ${username}`); +} + +db.close(); diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100644 index 0000000..b6a53c1 --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,389 @@ +#!/usr/bin/env bash +# ============================================================================= +# ETC PRS — Initial Deployment Script +# Targets: Ubuntu 24.04 LTS on a fresh Digital Ocean droplet +# Usage: sudo bash deploy.sh +# ============================================================================= + +set -euo pipefail + +# ── Colours ─────────────────────────────────────────────────────────────────── +RED='\033[0;31m'; YELLOW='\033[1;33m'; GREEN='\033[0;32m' +CYAN='\033[0;36m'; BOLD='\033[1m'; RESET='\033[0m' + +info() { echo -e "${CYAN}▸ $*${RESET}"; } +success() { echo -e "${GREEN}✓ $*${RESET}"; } +warn() { echo -e "${YELLOW}⚠ $*${RESET}"; } +error() { echo -e "${RED}✗ $*${RESET}"; exit 1; } +header() { echo -e "\n${BOLD}${CYAN}══ $* ══${RESET}\n"; } +divider() { echo -e "${CYAN}─────────────────────────────────────────────────${RESET}"; } + +# ── Root check ──────────────────────────────────────────────────────────────── +if [[ $EUID -ne 0 ]]; then + error "This script must be run as root (sudo bash deploy.sh)" +fi + +# ── Banner ──────────────────────────────────────────────────────────────────── +clear +echo -e "${BOLD}${CYAN}" +echo " ███████╗████████╗ ██████╗ ██████╗ ██████╗ ███████╗" +echo " ██╔════╝╚══██╔══╝██╔════╝ ██╔══██╗██╔══██╗██╔════╝" +echo " █████╗ ██║ ██║ ██████╔╝██████╔╝███████╗" +echo " ██╔══╝ ██║ ██║ ██╔═══╝ ██╔══██╗╚════██║" +echo " ███████╗ ██║ ╚██████╗ ██║ ██║ ██║███████║" +echo " ╚══════╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝╚══════╝" +echo -e "${RESET}" +echo -e " ${BOLD}Personality Editor — Initial Deployment${RESET}" +echo -e " ${CYAN}Targeting Ubuntu 24.04 · Node 20 · PM2 · Nginx · Certbot${RESET}" +divider +echo "" + +# ── Interactive prompts ─────────────────────────────────────────────────────── +header "Configuration" + +# Domain +while true; do + read -rp "$(echo -e "${BOLD}Domain name${RESET} (e.g. etcprs.app): ")" DOMAIN + DOMAIN="${DOMAIN#www.}" # strip leading www. if provided + DOMAIN="${DOMAIN%/}" # strip trailing slash + [[ -n "$DOMAIN" ]] && break + warn "Domain cannot be empty." +done + +# Gitea repo +echo "" +read -rp "$(echo -e "${BOLD}Gitea repo path${RESET} [default: raine/etc-prs-ui]: ")" REPO_PATH +REPO_PATH="${REPO_PATH:-raine/etc-prs-ui}" +REPO_URL="https://git.etcprs.app/${REPO_PATH}.git" +echo -e " ${CYAN}→ ${REPO_URL}${RESET}" + +# Gitea credentials (for private repos) +echo "" +echo -e "${YELLOW}If your Gitea repo is private, enter credentials below." +echo -e "Leave blank if the repo is public.${RESET}" +read -rp "$(echo -e "${BOLD}Gitea username${RESET} (blank = public): ")" GITEA_USER +if [[ -n "$GITEA_USER" ]]; then + read -rsp "$(echo -e "${BOLD}Gitea password / token${RESET}: ")" GITEA_PASS + echo "" + REPO_URL="https://${GITEA_USER}:${GITEA_PASS}@git.etcprs.app/${REPO_PATH}.git" +fi + +# Admin account +echo "" +echo -e "${BOLD}Create the first admin account:${RESET}" +while true; do + read -rp " Admin username (2–32 chars): " ADMIN_USER + [[ ${#ADMIN_USER} -ge 2 && ${#ADMIN_USER} -le 32 ]] && break + warn "Username must be 2–32 characters." +done +while true; do + read -rsp " Admin password (min 8 chars): " ADMIN_PASS + echo "" + [[ ${#ADMIN_PASS} -ge 8 ]] && break + warn "Password must be at least 8 characters." +done +read -rsp " Confirm password: " ADMIN_PASS_CONFIRM +echo "" +[[ "$ADMIN_PASS" == "$ADMIN_PASS_CONFIRM" ]] || error "Passwords do not match." + +# Rate limits +echo "" +echo -e "${BOLD}Rate limits${RESET} (press Enter to accept defaults):" +read -rp " Publish rate limit per IP/hour [default: 5]: " RATE_PUBLISH +RATE_PUBLISH="${RATE_PUBLISH:-5}" +read -rp " Read rate limit per IP/hour [default: 100]: " RATE_READ +RATE_READ="${RATE_READ:-100}" + +# SSL email +echo "" +read -rp "$(echo -e "${BOLD}Email for SSL certificate${RESET} (Let's Encrypt notices): ")" SSL_EMAIL +[[ -n "$SSL_EMAIL" ]] || error "SSL email is required." + +# ── Confirmation ────────────────────────────────────────────────────────────── +echo "" +divider +echo -e "${BOLD} Deployment Summary${RESET}" +divider +echo -e " Domain: ${CYAN}${DOMAIN}${RESET} (and www.${DOMAIN})" +echo -e " Repo: ${CYAN}${REPO_URL//:*@/:***@}${RESET}" +echo -e " Admin user: ${CYAN}${ADMIN_USER}${RESET}" +echo -e " Rate limits: publish=${RATE_PUBLISH}/hr read=${RATE_READ}/hr" +echo -e " SSL email: ${CYAN}${SSL_EMAIL}${RESET}" +divider +echo "" +read -rp "$(echo -e "${BOLD}Proceed with deployment? [y/N]: ${RESET}")" CONFIRM +[[ "${CONFIRM,,}" == "y" ]] || { echo "Aborted."; exit 0; } + +# ── Constants ───────────────────────────────────────────────────────────────── +APP_USER="prs" +APP_DIR="/opt/etc-prs/app" +DATA_DIR="/var/lib/etc-prs" +LOG_DIR="/var/log/etc-prs" +BACKUP_DIR="/var/backups/etc-prs" +BACKUP_SCRIPT="/opt/etc-prs/backup.sh" +NGINX_CONF="/etc/nginx/sites-available/etc-prs" + +# ── Step 1: System packages ─────────────────────────────────────────────────── +header "Step 1 / 9 — System Packages" + +info "Updating package lists…" +apt-get update -qq + +info "Upgrading installed packages…" +DEBIAN_FRONTEND=noninteractive apt-get upgrade -y -qq + +info "Installing build tools, Nginx, Certbot, SQLite…" +DEBIAN_FRONTEND=noninteractive apt-get install -y -qq \ + build-essential python3 git nginx certbot python3-certbot-nginx sqlite3 ufw curl + +info "Installing Node.js 20 LTS via NodeSource…" +curl -fsSL https://deb.nodesource.com/setup_20.x | bash - > /dev/null 2>&1 +DEBIAN_FRONTEND=noninteractive apt-get install -y -qq nodejs + +NODE_VER=$(node --version) +NPM_VER=$(npm --version) +success "Node ${NODE_VER} / npm ${NPM_VER} installed" + +info "Installing PM2 globally…" +npm install -g pm2 --quiet +success "PM2 $(pm2 --version) installed" + +# ── Step 2: Users & directories ─────────────────────────────────────────────── +header "Step 2 / 9 — Users & Directories" + +if ! id "$APP_USER" &>/dev/null; then + info "Creating system user '${APP_USER}'…" + useradd --system --shell /bin/false --home /opt/etc-prs "$APP_USER" + success "User '${APP_USER}' created" +else + success "User '${APP_USER}' already exists" +fi + +for DIR in /opt/etc-prs "$APP_DIR" "$DATA_DIR" "$LOG_DIR" "$BACKUP_DIR"; do + mkdir -p "$DIR" +done + +chown -R "${APP_USER}:${APP_USER}" /opt/etc-prs "$DATA_DIR" "$LOG_DIR" "$BACKUP_DIR" +success "Directories created and ownership set" + +# ── Step 3: Clone from Gitea ────────────────────────────────────────────────── +header "Step 3 / 9 — Clone Repository" + +info "Cloning from ${REPO_URL//:*@/:***@}…" + +if [[ -d "${APP_DIR}/.git" ]]; then + warn "App directory already contains a git repo — pulling latest instead." + sudo -u "$APP_USER" git -C "$APP_DIR" pull +else + sudo -u "$APP_USER" git clone "$REPO_URL" "$APP_DIR" 2>&1 | \ + sed 's/'"${GITEA_PASS:-NOPASS}"'/***REDACTED***/g' || \ + error "Git clone failed. Check your repo URL and credentials." +fi + +success "Repository cloned to ${APP_DIR}" + +# ── Step 4: Environment file ────────────────────────────────────────────────── +header "Step 4 / 9 — Environment Configuration" + +ENV_FILE="${APP_DIR}/.env" +cat > "$ENV_FILE" < "$ECOSYSTEM" <&1 | grep "sudo env") +if [[ -n "$PM2_STARTUP" ]]; then + eval "$PM2_STARTUP" +fi + +success "PM2 configured — app is running on 127.0.0.1:3000" + +# ── Step 7: Nginx ───────────────────────────────────────────────────────────── +header "Step 7 / 9 — Nginx" + +cat > "$NGINX_CONF" < "$BACKUP_SCRIPT" <<'EOF' +#!/usr/bin/env bash +DATE=$(date +%Y-%m-%d_%H%M) +BACKUP_DIR=/var/backups/etc-prs +DB_PATH=/var/lib/etc-prs/personalities.db + +mkdir -p "$BACKUP_DIR" +sqlite3 "$DB_PATH" ".backup ${BACKUP_DIR}/personalities-${DATE}.db" +gzip "${BACKUP_DIR}/personalities-${DATE}.db" + +# Keep only the last 30 days +find "$BACKUP_DIR" -name "personalities-*.db.gz" -mtime +30 -delete + +echo "$(date '+%Y-%m-%d %H:%M:%S') Backup OK: personalities-${DATE}.db.gz" +EOF + +chmod +x "$BACKUP_SCRIPT" + +# Add cron job (2am daily) — idempotent +CRON_LINE="0 2 * * * ${BACKUP_SCRIPT} >> ${LOG_DIR}/backup.log 2>&1" +( crontab -l 2>/dev/null | grep -v "$BACKUP_SCRIPT"; echo "$CRON_LINE" ) | crontab - +success "Backup cron job scheduled (daily at 2am)" + +# Firewall +info "Configuring UFW firewall…" +ufw allow OpenSSH > /dev/null +ufw allow 'Nginx Full' > /dev/null +ufw --force enable > /dev/null +success "Firewall enabled (SSH + HTTP + HTTPS)" + +# Admin account +info "Creating admin account '${ADMIN_USER}'…" +cd "$APP_DIR" +sudo -u "$APP_USER" node scripts/create-admin.js "$ADMIN_USER" "$ADMIN_PASS" || \ + warn "Admin creation failed — run manually: cd ${APP_DIR} && node scripts/create-admin.js " +success "Admin account '${ADMIN_USER}' created" + +# ── Done ────────────────────────────────────────────────────────────────────── +echo "" +divider +echo -e "${BOLD}${GREEN} Deployment complete!${RESET}" +divider +echo "" +echo -e " ${BOLD}Site:${RESET} https://${DOMAIN}" +echo -e " ${BOLD}Admin panel:${RESET} https://${DOMAIN}/admin" +echo -e " ${BOLD}App logs:${RESET} sudo -u ${APP_USER} pm2 logs etc-prs" +echo -e " ${BOLD}DB path:${RESET} ${DATA_DIR}/personalities.db" +echo -e " ${BOLD}Backups:${RESET} ${BACKUP_DIR}" +echo "" +echo -e " ${BOLD}Redeploy after a push:${RESET}" +echo -e " ${CYAN} cd ${APP_DIR} && sudo -u ${APP_USER} git pull && \\" +echo -e " sudo -u ${APP_USER} npm install && \\" +echo -e " sudo -u ${APP_USER} npm run build && \\" +echo -e " sudo -u ${APP_USER} pm2 reload etc-prs${RESET}" +echo "" +divider +echo "" + +# ── Verify app is responding ────────────────────────────────────────────────── +info "Checking app is responding on port 3000…" +sleep 2 +if curl -sf http://127.0.0.1:3000 > /dev/null; then + success "App is up and responding ✓" +else + warn "App did not respond on port 3000. Check logs:" + warn " sudo -u ${APP_USER} pm2 logs etc-prs --lines 30" +fi + +echo "" diff --git a/scripts/import-personalities.js b/scripts/import-personalities.js new file mode 100644 index 0000000..966725d --- /dev/null +++ b/scripts/import-personalities.js @@ -0,0 +1,273 @@ +#!/usr/bin/env node +/** + * Bulk import personalities from a JSON manifest + directory of .prs files. + * + * Usage: + * node scripts/import-personalities.js [options] + * + * Options: + * --dry-run Print what would be imported without writing to DB + * --creator Creator handle to tag all imports with (default: "ETC Library") + * --skip-existing Skip fixtures already in DB (matched by prs_name + manufacturer) + * + * Examples: + * node scripts/import-personalities.js personalities.json ./prs + * node scripts/import-personalities.js personalities.json ./prs --dry-run + * node scripts/import-personalities.js personalities.json ./prs --creator "Raine" + */ + +import { readFileSync, existsSync } from 'fs'; +import { join, resolve } from 'path'; +import { randomBytes } from 'crypto'; +import Database from 'better-sqlite3'; +import bcrypt from 'bcryptjs'; +import { config } from 'dotenv'; +import { fileURLToPath } from 'url'; +import { dirname } from 'path'; +import { nanoid } from 'nanoid'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +config({ path: join(__dirname, '..', '.env') }); + +// ── Args ───────────────────────────────────────────────────────── +const args = process.argv.slice(2); +const jsonPath = args[0]; +const prsDir = args[1]; +const DRY_RUN = args.includes('--dry-run'); +const SKIP_EXISTING = args.includes('--skip-existing'); +const creatorIdx = args.indexOf('--creator'); +const CREATOR = creatorIdx !== -1 ? args[creatorIdx + 1] : 'ETC Library'; + +if (!jsonPath || !prsDir) { + console.error('Usage: node scripts/import-personalities.js [--dry-run] [--skip-existing] [--creator ]'); + process.exit(1); +} + +if (!existsSync(jsonPath)) { + console.error(`JSON file not found: ${jsonPath}`); + process.exit(1); +} + +if (!existsSync(prsDir)) { + console.error(`PRS directory not found: ${prsDir}`); + process.exit(1); +} + +// ── Constants ──────────────────────────────────────────────────── +const FILE_SIZE = 540; // ETC PRS files are always 540 bytes +const NAME_LEN = 12; +const NAME_OFFSET = 0; + +function readPrsName(bytes) { + const raw = bytes.slice(NAME_OFFSET, NAME_OFFSET + NAME_LEN); + let name = ''; + for (const b of raw) { + if (b === 0) break; + name += String.fromCharCode(b); + } + return name.trim(); +} + +// ── DB setup ───────────────────────────────────────────────────── +const dbPath = process.env.DATABASE_URL ?? './dev.db'; +const db = new Database(dbPath); +db.pragma('journal_mode = WAL'); +db.pragma('foreign_keys = ON'); + +// Ensure base table exists (safe no-op if already present) +db.exec(` + CREATE TABLE IF NOT EXISTS personalities ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + prs_name TEXT, + file_name TEXT, + notes TEXT, + data BLOB NOT NULL, + manufacturer TEXT, + tags TEXT NOT NULL DEFAULT '[]', + channel_count INTEGER NOT NULL, + created_at TEXT NOT NULL, + creator_handle TEXT, + view_count INTEGER NOT NULL DEFAULT 0, + owner_token_hash TEXT NOT NULL, + deleted_at TEXT DEFAULT NULL + ) +`); + +// Add prs_name column if it doesn't exist yet +const cols = db.prepare('PRAGMA table_info(personalities)').all().map(r => r.name); +if (!cols.includes('prs_name')) { + db.exec('ALTER TABLE personalities ADD COLUMN prs_name TEXT DEFAULT NULL'); + console.log(' ℹ Added prs_name column to existing DB.'); +} + +const insertStmt = db.prepare(` + INSERT INTO personalities + (id, name, prs_name, file_name, notes, data, manufacturer, tags, + channel_count, created_at, creator_handle, owner_token_hash) + VALUES + (@id, @name, @prs_name, @file_name, @notes, @data, @manufacturer, @tags, + @channel_count, @created_at, @creator_handle, @owner_token_hash) +`); + +const existsStmt = db.prepare(` + SELECT id FROM personalities + WHERE prs_name = ? AND manufacturer = ? AND deleted_at IS NULL + LIMIT 1 +`); + +// ── Slug helper ────────────────────────────────────────────────── +function makeSlug(str) { + return str.toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 80); +} + +// ── Load manifest ───────────────────────────────────────────────── +const manifest = JSON.parse(readFileSync(jsonPath, 'utf8')); +console.log(`\nETC PRS Bulk Import`); +console.log(`${'─'.repeat(50)}`); +console.log(` Manifest: ${jsonPath}`); +console.log(` PRS dir: ${resolve(prsDir)}`); +console.log(` Database: ${dbPath}`); +console.log(` Creator: ${CREATOR}`); +console.log(` Dry run: ${DRY_RUN ? 'YES — nothing will be written' : 'no'}`); +console.log(` Total packs: ${manifest.total_packs}`); +console.log(` Total PRS: ${manifest.total_fixtures}`); +console.log(`${'─'.repeat(50)}\n`); + +// ── Import ─────────────────────────────────────────────────────── +let imported = 0; +let skipped = 0; +let missing = 0; +let errors = 0; +let existing = 0; + +// Use a single bcrypt hash for all imports (avoids 160 × slow bcrypt calls) +// Each entry gets a unique nanoid token; we hash one representative value. +// In practice these are bulk "library" entries — owner-token deletion is +// less relevant, but we still store a valid hash so the schema stays consistent. +const sharedTokenBase = nanoid(32); +const sharedTokenHash = DRY_RUN ? 'dryrun' : bcrypt.hashSync(sharedTokenBase, 10); + +const doImport = db.transaction((records) => { + for (const r of records) { + insertStmt.run(r); + } +}); + +const batchRecords = []; + +for (const pack of manifest.packs) { + const { manufacturer, category, fixtures, prs_files } = pack; + + if (!fixtures || fixtures.length === 0) { + console.log(` ⚪ ${manufacturer} — no fixtures, skipping pack`); + continue; + } + + console.log(` 📦 ${pack.name} (${fixtures.length} fixtures)`); + + for (const fixture of fixtures) { + const { fixture_name, channels, mode_info, prs_file } = fixture; + + // Find the matching .prs filename from prs_files list + const prsFileName = prs_files.find(f => + f.toLowerCase().includes(prs_file.toLowerCase()) + ); + + if (!prsFileName) { + console.log(` ✗ ${fixture_name} — no matching PRS file for key "${prs_file}"`); + missing++; + continue; + } + + const prsPath = join(prsDir, prsFileName); + if (!existsSync(prsPath)) { + console.log(` ✗ ${fixture_name} — file not found: ${prsFileName}`); + missing++; + continue; + } + + // Read and validate binary + const data = readFileSync(prsPath); + if (data.length !== FILE_SIZE) { + console.log(` ✗ ${fixture_name} — invalid file size ${data.length} (expected ${FILE_SIZE})`); + errors++; + continue; + } + + // Read PRS name from binary + const prsName = readPrsName(data); + + // Check for existing entry + if (SKIP_EXISTING) { + const dup = existsStmt.get(prsName, manufacturer); + if (dup) { + console.log(` ~ ${fixture_name} — already in DB, skipping`); + existing++; + continue; + } + } + + // Build display name: "Fixture Name (mode_info)" if mode_info available + const displayName = mode_info + ? `${fixture_name} (${mode_info})` + : fixture_name; + + // Tags: mode_info + category + const tags = []; + if (mode_info) tags.push(mode_info.toLowerCase()); + if (category) tags.push(category.toLowerCase()); + + const channelCount = channels ?? data[0x0d]; // use JSON value or read from binary + const now = new Date().toISOString(); + const id = nanoid(10); + + const record = { + id, + name: displayName.slice(0, 120), + prs_name: prsName.slice(0, NAME_LEN), + file_name: prsFileName, + notes: '', + data: data, + manufacturer: manufacturer, + tags: JSON.stringify(tags), + channel_count: channelCount, + created_at: now, + creator_handle: CREATOR, + owner_token_hash: sharedTokenHash, + }; + + if (DRY_RUN) { + console.log(` ✓ [DRY] ${displayName} — ${channelCount}ch — PRS: ${prsName} — ${prsFileName}`); + } else { + batchRecords.push(record); + console.log(` ✓ ${displayName} — ${channelCount}ch — PRS: ${prsName}`); + } + + imported++; + } +} + +// Write all records in a single transaction +if (!DRY_RUN && batchRecords.length > 0) { + doImport(batchRecords); +} + +// ── Summary ─────────────────────────────────────────────────────── +console.log(`\n${'─'.repeat(50)}`); +if (DRY_RUN) { + console.log(` DRY RUN — no changes made to database`); + console.log(` Would import: ${imported}`); +} else { + console.log(` ✓ Imported: ${imported}`); +} +if (existing > 0) console.log(` ~ Skipped (existing): ${existing}`); +if (skipped > 0) console.log(` ⚪ Skipped: ${skipped}`); +if (missing > 0) console.log(` ✗ Missing: ${missing}`); +if (errors > 0) console.log(` ✗ Errors: ${errors}`); +console.log(`${'─'.repeat(50)}\n`); + +db.close(); diff --git a/src/app.css b/src/app.css new file mode 100644 index 0000000..aeb72dc --- /dev/null +++ b/src/app.css @@ -0,0 +1,209 @@ +@import url('https://fonts.googleapis.com/css2?family=DM+Mono:ital,wght@0,300;0,400;0,500;1,400&family=Barlow:wght@300;400;500;600&family=Barlow+Condensed:wght@400;500;600;700&display=swap'); + +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + color-scheme: dark; + + --bg: #0d0f0e; + --surface: #131713; + --raised: #191e19; + --border: #263026; + --border2: #2f3b2f; + --amber: #e8930a; + --amber2: #f5b730; + --amber-glow: rgba(232, 147, 10, 0.18); + --amber-dim: rgba(232, 147, 10, 0.08); + --cyan: #2dd4c8; + --cyan-dim: rgba(45, 212, 200, 0.10); + --magenta: #d946a8; + --magenta-dim: rgba(217, 70, 168, 0.10); + --green: #4ade80; + --green-dim: rgba(74, 222, 128, 0.10); + --red: #f87171; + --red-dim: rgba(248, 113, 113, 0.10); + --text: #d4dbd4; + --text2: #7a9478; + --text3: #3d5c3d; +} + +html, body { + @apply min-h-screen antialiased; + font-family: 'Barlow', sans-serif; + background-color: var(--bg); + color: var(--text); + + /* Faint grid texture evoking a console display */ + background-image: + radial-gradient(ellipse 60% 30% at 50% 0%, rgba(232,147,10,0.07) 0%, transparent 70%), + repeating-linear-gradient(0deg, transparent, transparent 23px, rgba(255,255,255,0.018) 23px, rgba(255,255,255,0.018) 24px), + repeating-linear-gradient(90deg, transparent, transparent 23px, rgba(255,255,255,0.012) 23px, rgba(255,255,255,0.012) 24px); +} + +body { + selection-color: var(--amber); + @apply selection:bg-amber-500/25 selection:text-amber-100; +} + +/* ── BUTTONS ── */ +.btn { + @apply inline-flex items-center gap-2 px-3 py-2 text-sm font-medium transition + disabled:cursor-not-allowed disabled:opacity-40; + font-family: 'Barlow Condensed', sans-serif; + font-weight: 600; + letter-spacing: 0.05em; + text-transform: uppercase; + font-size: 14px; + border-radius: 4px; + border: 1px solid var(--border2); + background: var(--raised); + color: var(--text2); +} +.btn:hover:not(:disabled) { + border-color: var(--text3); + color: var(--text); + background: #1f271f; +} +.btn:active:not(:disabled) { + background: #161d16; +} + +.btn-primary { + border-color: var(--amber); + background: rgba(232,147,10,0.12); + color: var(--amber); + box-shadow: 0 0 10px rgba(232,147,10,0.15); +} +.btn-primary:hover:not(:disabled) { + background: rgba(232,147,10,0.22); + border-color: var(--amber2); + color: var(--amber2); + box-shadow: 0 0 16px rgba(232,147,10,0.25); +} + +.btn-danger { + border-color: rgba(248,113,113,0.3); + background: var(--red-dim); + color: var(--red); +} +.btn-danger:hover:not(:disabled) { + background: rgba(248,113,113,0.18); +} + +/* ── INPUTS ── */ +.input, .select { + @apply w-full px-3 py-2 text-sm outline-none transition; + font-family: 'DM Mono', monospace; + border-radius: 3px; + border: 1px solid var(--border2); + background: var(--bg); + color: var(--text); +} +.input:focus, .select:focus { + border-color: var(--amber); + box-shadow: 0 0 0 2px rgba(232,147,10,0.12); +} +.input::placeholder { + color: var(--text3); +} +.select { + cursor: pointer; + appearance: none; + -webkit-appearance: none; +} + +textarea.input { + font-family: 'Barlow', sans-serif; + font-size: 13px; +} + +/* ── PANELS ── */ +.panel { + border-radius: 4px; + border: 1px solid var(--border); + background: var(--surface); + box-shadow: inset 0 1px 0 rgba(255,255,255,0.03), 0 4px 16px rgba(0,0,0,0.4); +} + +.menu-panel { + position: absolute; + left: 0; + top: 100%; + z-index: 30; + margin-top: 6px; + min-width: 320px; + border-radius: 4px; + border: 1px solid var(--border2); + background: #111511; + padding: 8px; + box-shadow: 0 16px 48px rgba(0,0,0,0.7), 0 0 0 1px rgba(232,147,10,0.06); + backdrop-filter: blur(12px); +} + +/* ── TYPOGRAPHY UTILITIES ── */ +.label { + font-family: 'Barlow Condensed', sans-serif; + font-size: 13px; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--text2); +} + +.subtle { + font-size: 13px; + color: var(--text2); +} + +/* ── BADGES ── */ +.badge { + display: inline-flex; + align-items: center; + padding: 3px 8px; + border-radius: 3px; + font-family: 'Barlow Condensed', sans-serif; + font-size: 14px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + border: 1px solid var(--border2); + background: var(--raised); + color: var(--text2); +} + +/* ── LED READOUT — for stat cells ── */ +.led-readout { + font-family: 'DM Mono', monospace; + font-size: 22px; + font-weight: 500; + color: var(--amber); + text-shadow: 0 0 12px rgba(232,147,10,0.5); + line-height: 1; +} +.led-readout.cyan { color: var(--cyan); text-shadow: 0 0 12px rgba(45,212,200,0.4); } +.led-readout.green { color: var(--green); text-shadow: 0 0 12px rgba(74,222,128,0.4); } +.led-readout.dim { color: var(--text2); text-shadow: none; font-size: 16px; } + +/* ── ATTRIBUTE COLOR CODING ── */ +/* Applied to channel card top-border and badge accents */ +.attr-intensity { --attr-color: var(--amber); --attr-dim: var(--amber-dim); } +.attr-movement { --attr-color: var(--cyan); --attr-dim: var(--cyan-dim); } +.attr-color { --attr-color: var(--magenta); --attr-dim: var(--magenta-dim); } +.attr-beam { --attr-color: #a78bfa; --attr-dim: rgba(167,139,250,0.10); } +.attr-control { --attr-color: var(--text2); --attr-dim: rgba(122,148,120,0.10); } +.attr-none { --attr-color: var(--border2); --attr-dim: transparent; } + +.channel-card-accent { + border-top: 2px solid var(--attr-color, var(--border2)); +} +.channel-card-accent:hover { + box-shadow: 0 0 0 1px var(--attr-color, var(--border2)), inset 0 1px 0 rgba(255,255,255,0.03); +} + +/* ── SCROLLBAR ── */ +::-webkit-scrollbar { width: 5px; height: 5px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 99px; } +::-webkit-scrollbar-thumb:hover { background: var(--text3); } diff --git a/src/app.html b/src/app.html new file mode 100644 index 0000000..adf8bd8 --- /dev/null +++ b/src/app.html @@ -0,0 +1,11 @@ + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/src/lib/components/ChannelCardGrid.svelte b/src/lib/components/ChannelCardGrid.svelte new file mode 100644 index 0000000..a09bd69 --- /dev/null +++ b/src/lib/components/ChannelCardGrid.svelte @@ -0,0 +1,163 @@ + + +
+ {#each entries as entry (entry.key)} + {@const ac = attrClass(entry.leader.attribute)} +
onDrop(entry.key, event.clientY, false)} + on:drop={(event) => onDrop(entry.key, event.clientY, true)} + > + +
+
+ +
+ {entry.isPair ? `CH ${entry.leader.channel} / ${entry.follower.channel}` : `CH ${entry.leader.channel}`} + {#if entry.isPair} + 16-BIT + {/if} +
+ + {#if editable} + + {:else} +
{entry.leader.attribute || 'Not Used'}
+ {/if} +
+ {#if editable} +
+ + +
+ {/if} +
+ + +
+ + +
+
Home
+ {#if editable} + onUpdate(entry.key, { home: Number(e.currentTarget.value) })} /> + {:else} +
{entry.home}
+ {/if} +
+ + +
+
Display
+ {#if editable} + + {:else} +
{entry.displayFormat}
+ {/if} +
+ + +
+
Flags
+ {#if editable} +
+ {#each ['Independent', 'LTP', 'Flipped'] as flag} + + {/each} + +
+ {:else} +
+ {#if entry.leader.flags.filter(f => f !== '16-bit').length} + {#each entry.leader.flags.filter(f => f !== '16-bit') as flag} + {flag} + {/each} + {:else} + None + {/if} +
+ {/if} +
+ +
+
+ {/each} +
diff --git a/src/lib/components/ChannelTable.svelte b/src/lib/components/ChannelTable.svelte new file mode 100644 index 0000000..d47ef5e --- /dev/null +++ b/src/lib/components/ChannelTable.svelte @@ -0,0 +1,165 @@ + + +
+
+ + + + + + + + + {#if editable} + + + {/if} + + + + {#each entries as entry (entry.key)} + onDrop(entry.key, event.clientY, false)} + on:drop={(event) => onDrop(entry.key, event.clientY, true)} + on:mouseenter={(e) => { if (draggingKey !== entry.key) e.currentTarget.style.background = 'var(--raised)'; }} + on:mouseleave={(e) => { e.currentTarget.style.background = ''; }} + > + + + + + + + + + + + + + + + + {#if editable} + + + {/if} + + {/each} + +
ChAttributeFlagsHomeDisplay
+ {entry.isPair ? `${entry.leader.channel} / ${entry.follower.channel}` : entry.leader.channel} + {#if entry.isPair} + 16B + {/if} + + {#if editable} + + {:else} + + {entry.leader.attribute || 'Not Used'} + + {/if} + + {#if editable} +
+ {#each ['Independent', 'LTP', 'Flipped'] as flag} + + {/each} + +
+ {:else} + {entry.flagsLabel} + {/if} +
+ {#if editable} + onUpdate(entry.key, { home: Number(e.currentTarget.value) })} /> + {:else} + {entry.home} + {/if} + + {#if editable} + + {:else} + {entry.displayFormat} + {/if} + + + + +
+
+
diff --git a/src/lib/components/ClearDataModal.svelte b/src/lib/components/ClearDataModal.svelte new file mode 100644 index 0000000..e1734ea --- /dev/null +++ b/src/lib/components/ClearDataModal.svelte @@ -0,0 +1,108 @@ + + + diff --git a/src/lib/components/DropdownMenu.svelte b/src/lib/components/DropdownMenu.svelte new file mode 100644 index 0000000..98b5923 --- /dev/null +++ b/src/lib/components/DropdownMenu.svelte @@ -0,0 +1,65 @@ + + +
(open = false)}> + + + {#if open} +
+ {#if items.length > 8} +
+ +
+ {/if} + + {#if filteredItems.length} +
+ {#each filteredItems as item} + + {/each} +
+ {:else} +
{normalizedQuery ? 'No matching items.' : emptyText}
+ {/if} +
+ {/if} +
diff --git a/src/lib/components/Footer.svelte b/src/lib/components/Footer.svelte new file mode 100644 index 0000000..5b02d34 --- /dev/null +++ b/src/lib/components/Footer.svelte @@ -0,0 +1,57 @@ + diff --git a/src/lib/components/GlobalMenu.svelte b/src/lib/components/GlobalMenu.svelte new file mode 100644 index 0000000..929da35 --- /dev/null +++ b/src/lib/components/GlobalMenu.svelte @@ -0,0 +1,262 @@ + + +
+
+ + +
+
+
+
ETC PRS
+
+ Personality Editor +
+
+
+ + +
+
+ {subtitleText} +
+
+ + +
+ + + {#if currentMode !== 'root'} + + {/if} + + + + + + + + Library + + + +
+ + + {#if fileMenuOpen} + + {/if} +
+ + + onOpenFile(event)} /> + + + {#if openReportCount !== null} + e.currentTarget.style.color='var(--red)'} + on:mouseleave={(e) => e.currentTarget.style.color='var(--text3)'} + title="Admin panel"> + ⚙ + {#if openReportCount > 0} + + {openReportCount > 99 ? '99+' : openReportCount} + + {/if} + + {/if} + +
+
+
diff --git a/src/lib/components/PublishModal.svelte b/src/lib/components/PublishModal.svelte new file mode 100644 index 0000000..c0b41e1 --- /dev/null +++ b/src/lib/components/PublishModal.svelte @@ -0,0 +1,155 @@ + + + diff --git a/src/lib/components/PublishSuccessModal.svelte b/src/lib/components/PublishSuccessModal.svelte new file mode 100644 index 0000000..4839f60 --- /dev/null +++ b/src/lib/components/PublishSuccessModal.svelte @@ -0,0 +1,115 @@ + + + diff --git a/src/lib/components/ReportModal.svelte b/src/lib/components/ReportModal.svelte new file mode 100644 index 0000000..5f13198 --- /dev/null +++ b/src/lib/components/ReportModal.svelte @@ -0,0 +1,144 @@ + + + diff --git a/src/lib/components/SecondaryBar.svelte b/src/lib/components/SecondaryBar.svelte new file mode 100644 index 0000000..5d440ec --- /dev/null +++ b/src/lib/components/SecondaryBar.svelte @@ -0,0 +1,133 @@ + + +
+
+ + +
+ {#if mode === 'viewer'} + + + +
+ + {:else} + + + + +
+ + {/if} +
+ + +
+ + +
+ + + +
+ + +
+ + +
+ +
+ +
+ +
+
+
diff --git a/src/lib/components/TagInput.svelte b/src/lib/components/TagInput.svelte new file mode 100644 index 0000000..f6dbbf5 --- /dev/null +++ b/src/lib/components/TagInput.svelte @@ -0,0 +1,63 @@ + + +
document.getElementById('tag-input-field').focus()} + role="none" +> + {#each tags as tag} + + {tag} + + + {/each} + + +
+
+ Press Enter or comma to add · {tags.length}/10 +
diff --git a/src/lib/components/Toast.svelte b/src/lib/components/Toast.svelte new file mode 100644 index 0000000..a620dd7 --- /dev/null +++ b/src/lib/components/Toast.svelte @@ -0,0 +1,24 @@ + + +{#if visible} +
+ + {message} +
+{/if} diff --git a/src/lib/prs.js b/src/lib/prs.js new file mode 100644 index 0000000..c300fc9 --- /dev/null +++ b/src/lib/prs.js @@ -0,0 +1,320 @@ +export const FILE_SIZE = 540; +export const CHANNEL_COUNT_OFFSET = 0x0d; +export const NAME_OFFSET = 0x0e; +export const NAME_LEN = 12; +export const CHANNEL_BLOCK_OFFSET = 0x1c; +export const CHANNEL_RECORD_SIZE = 8; +export const MAX_CHANNEL_RECORDS = 64; + +export const ATTRIBUTE_NAMES = [ + 'Not Used', 'Intens', 'Pan', 'Tilt', 'Color', 'Color2', 'Cyan', 'Magenta', 'Yellow', 'Gobo', + 'GoboRo', 'Gobo2', 'Gobo2R', 'F/X', 'F/X Ro', 'Prism', 'Strobe', 'Zoom', 'Focus', 'Iris', + 'Frost', 'Pan Ro', 'Tilt Ro', 'Beam1a', 'Beam1b', 'Beam2a', 'Beam2b', 'Beam3a', 'Beam3b', + 'Beam4a', 'Beam4b', 'BeamRo', 'Speed', 'Speed2', 'Contrl', 'Contr2', 'Resrv9', 'Resrv8', + 'Resrv7', 'Resrv6', 'Resrv5', 'Resrv4', 'Resrv3', 'Resrv2', 'Resrv1', 'ClrFnc', 'LensW/l', + 'ChkSum', 'User17', 'User16', 'User15', 'User14', 'User13', 'User12', 'User11', 'User10', + 'User9', 'User8', 'User7', 'User6', 'User5', 'User4', 'User3', 'User2', 'User1' +]; + +export const DISPLAY_FORMATS = ['Percent', 'Raw', 'Hex', 'Text']; +export const FLAGS = { + 0x01: 'Independent', + 0x02: 'LTP', + 0x04: '16-bit', + 0x08: 'Flipped' +}; + +const textDecoder = new TextDecoder('ascii'); +const textEncoder = new TextEncoder(); + +export function parseFlags(flagByte) { + return Object.entries(FLAGS) + .filter(([bit]) => flagByte & Number(bit)) + .map(([, name]) => name); +} + +export function composeFlags(flagNames = []) { + let value = 0; + for (const [bit, name] of Object.entries(FLAGS)) { + if (flagNames.includes(name)) value |= Number(bit); + } + return value; +} + +export function ensureChannelDefaults(channel = {}, index = 0) { + const attributeId = Number.isInteger(channel.attributeId) + ? channel.attributeId + : ATTRIBUTE_NAMES.indexOf(channel.attribute ?? ''); + const displayFormatId = Number.isInteger(channel.displayFormatId) + ? channel.displayFormatId + : Math.max(0, DISPLAY_FORMATS.indexOf(channel.displayFormat ?? 'Percent')); + + return { + uid: channel.uid ?? cryptoLikeId('ch'), + channel: Number.isInteger(channel.channel) ? channel.channel : index + 1, + attributeId: attributeId >= 0 ? attributeId : null, + attribute: attributeId >= 0 ? ATTRIBUTE_NAMES[attributeId] : '', + home: Number.isInteger(channel.home) ? channel.home : 0, + displayFormatId, + displayFormat: DISPLAY_FORMATS[displayFormatId] ?? DISPLAY_FORMATS[0], + rawFlagByte: Number.isInteger(channel.rawFlagByte) ? channel.rawFlagByte : 0, + flags: Array.isArray(channel.flags) ? [...channel.flags] : [], + is16BitPairLeader: Boolean(channel.is16BitPairLeader), + is16BitPairFollower: Boolean(channel.is16BitPairFollower), + pairOwnerUid: channel.pairOwnerUid ?? null + }; +} + +export function parsePRS(input) { + const bytes = input instanceof Uint8Array ? input : new Uint8Array(input); + if (bytes.length !== FILE_SIZE) { + throw new Error(`Expected ${FILE_SIZE} bytes, got ${bytes.length}.`); + } + + const channelCount = bytes[CHANNEL_COUNT_OFFSET]; + if (channelCount > MAX_CHANNEL_RECORDS) { + throw new Error(`Channel count ${channelCount} exceeds max ${MAX_CHANNEL_RECORDS}.`); + } + + const rawName = bytes.slice(NAME_OFFSET, NAME_OFFSET + NAME_LEN); + const nullIndex = rawName.indexOf(0); + const nameBytes = nullIndex === -1 ? rawName : rawName.slice(0, nullIndex); + const name = textDecoder.decode(nameBytes); + + const channels = []; + for (let i = 0; i < channelCount; i += 1) { + const offset = CHANNEL_BLOCK_OFFSET + i * CHANNEL_RECORD_SIZE; + const flagByte = bytes[offset + 0]; + const attributeId = bytes[offset + 4]; + const home = bytes[offset + 5]; + const displayFormatId = bytes[offset + 6]; + const zeroBasedChannel = bytes[offset + 7]; + + channels.push(ensureChannelDefaults({ + channel: zeroBasedChannel + 1, + attributeId, + home, + displayFormatId, + rawFlagByte: flagByte, + flags: parseFlags(flagByte) + }, i)); + } + + return { + name, + channelCount, + channels: annotate16BitPairs(channels) + }; +} + +export function annotate16BitPairs(channels) { + const normalized = channels.map(ensureChannelDefaults); + return normalized.map((channel, index) => { + // Leader: strictly determined by the 16-bit flag on this channel + const isLeader = channel.flags.includes('16-bit'); + // Follower: strictly determined by the previous channel having the 16-bit flag + const isFollower = index > 0 && normalized[index - 1].flags.includes('16-bit'); + return { + ...channel, + is16BitPairLeader: isLeader, + is16BitPairFollower: isFollower, + pairOwnerUid: isFollower ? normalized[index - 1].uid : null + }; + }); +} + +export function createBlankPersonality() { + return { + name: '', + channelCount: 0, + channels: [] + }; +} + +export function normalizePersonality(personality) { + const channels = annotate16BitPairs((personality?.channels ?? []).map(ensureChannelDefaults)); + channels.forEach((channel, index) => { + channel.channel = index + 1; + }); + return { + name: String(personality?.name ?? '').slice(0, NAME_LEN), + channelCount: channels.length, + channels + }; +} + +export function buildPRS(personality) { + const normalized = normalizePersonality(personality); + const emitted = normalized.channels.filter((channel) => Number.isInteger(channel.attributeId)); + if (emitted.length > MAX_CHANNEL_RECORDS) { + throw new Error(`Too many emitted channels. Max is ${MAX_CHANNEL_RECORDS}.`); + } + + const out = new Uint8Array(FILE_SIZE); + out[CHANNEL_COUNT_OFFSET] = emitted.length; + + const name = String(normalized.name ?? '').slice(0, NAME_LEN); + const encoded = textEncoder.encode(name); + out.set(encoded.slice(0, NAME_LEN), NAME_OFFSET); + + emitted.forEach((channel, index) => { + const offset = CHANNEL_BLOCK_OFFSET + index * CHANNEL_RECORD_SIZE; + out[offset + 0] = composeFlags(channel.flags); + out[offset + 1] = 0; + out[offset + 2] = 0; + out[offset + 3] = 0; + out[offset + 4] = channel.attributeId; + out[offset + 5] = Math.max(0, Math.min(255, Number(channel.home || 0))); + out[offset + 6] = Math.max(0, Math.min(DISPLAY_FORMATS.length - 1, Number(channel.displayFormatId || 0))); + out[offset + 7] = index; + }); + + return out; +} + +export function cryptoLikeId(prefix = 'id') { + return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; +} + +export function buildVisibleEntries(channels) { + const normalized = normalizePersonality({ channels }).channels; + const entries = []; + + for (let index = 0; index < normalized.length; index += 1) { + const leader = normalized[index]; + if (leader.is16BitPairFollower) continue; + + const follower = leader.is16BitPairLeader ? normalized[index + 1] : null; + entries.push({ + key: leader.uid, + startIndex: index, + endIndex: follower ? index + 1 : index, + isPair: Boolean(follower), + title: follower ? `${leader.channel}/${follower.channel} - ${leader.attribute || 'Not Used'}` : `${leader.channel} - ${leader.attribute || 'Not Used'}`, + leader, + follower, + home: follower ? follower.home : leader.home, + displayFormatId: follower ? follower.displayFormatId : leader.displayFormatId, + displayFormat: follower ? follower.displayFormat : leader.displayFormat, + flagsLabel: leader.flags.length ? leader.flags.join(', ') : 'No flags' + }); + } + + return entries; +} + +export function toggle16BitPair(channels, leaderUid, enabled) { + const normalized = normalizePersonality({ channels }).channels; + const index = normalized.findIndex((channel) => channel.uid === leaderUid); + if (index === -1) return normalized; + + const leader = normalized[index]; + const follower = normalized[index + 1]; + const currentlyPaired = leader.flags.includes('16-bit'); + + if (enabled && !currentlyPaired) { + leader.flags = mergeFlags(leader.flags, ['16-bit']); + const mirroredFlags = leader.flags.filter((flag) => flag !== '16-bit'); + normalized.splice(index + 1, 0, ensureChannelDefaults({ + channel: leader.channel + 1, + attributeId: leader.attributeId, + home: leader.home, + displayFormatId: leader.displayFormatId, + rawFlagByte: composeFlags(mirroredFlags), + flags: mirroredFlags, + is16BitPairFollower: true, + pairOwnerUid: leader.uid + }, index + 1)); + } + + if (!enabled && currentlyPaired) { + leader.flags = leader.flags.filter((flag) => flag !== '16-bit'); + normalized.splice(index + 1, 1); + } + + return normalizePersonality({ channels: normalized }).channels; +} + +export function updateEntry(channels, entryKey, patch) { + const normalized = normalizePersonality({ channels }).channels; + const index = normalized.findIndex((channel) => channel.uid === entryKey); + if (index === -1) return normalized; + + const leader = normalized[index]; + const follower = leader.flags.includes('16-bit') && normalized[index + 1] + ? normalized[index + 1] + : null; + + if ('attributeId' in patch) { + leader.attributeId = patch.attributeId; + leader.attribute = ATTRIBUTE_NAMES[patch.attributeId] ?? ''; + if (follower) { + follower.attributeId = patch.attributeId; + follower.attribute = leader.attribute; + } + } + + if ('flags' in patch) { + leader.flags = [...patch.flags]; + if (follower) { + follower.flags = patch.flags.filter((flag) => flag !== '16-bit'); + } + } + + if ('home' in patch) { + if (follower) follower.home = patch.home; + else leader.home = patch.home; + } + + if ('displayFormatId' in patch) { + if (follower) { + follower.displayFormatId = patch.displayFormatId; + follower.displayFormat = DISPLAY_FORMATS[patch.displayFormatId] ?? DISPLAY_FORMATS[0]; + } else { + leader.displayFormatId = patch.displayFormatId; + leader.displayFormat = DISPLAY_FORMATS[patch.displayFormatId] ?? DISPLAY_FORMATS[0]; + } + } + + return normalizePersonality({ channels: normalized }).channels; +} + +export function addChannel(channels) { + const normalized = normalizePersonality({ channels }).channels; + normalized.push(ensureChannelDefaults({ + channel: normalized.length + 1, + attributeId: 0, + home: 0, + displayFormatId: 0, + flags: [] + }, normalized.length)); + return normalizePersonality({ channels: normalized }).channels; +} + +export function deleteEntry(channels, entryKey) { + const entries = buildVisibleEntries(channels); + const entry = entries.find((item) => item.key === entryKey); + if (!entry) return normalizePersonality({ channels }).channels; + const normalized = normalizePersonality({ channels }).channels; + normalized.splice(entry.startIndex, entry.isPair ? 2 : 1); + return normalizePersonality({ channels: normalized }).channels; +} + +export function reorderEntries(channels, activeKey, targetKey, position = 'after') { + const normalized = normalizePersonality({ channels }).channels; + const entries = buildVisibleEntries(normalized); + const activeIndex = entries.findIndex((entry) => entry.key === activeKey); + const targetIndex = entries.findIndex((entry) => entry.key === targetKey); + if (activeIndex === -1 || targetIndex === -1 || activeIndex === targetIndex) return normalized; + + const groups = entries.map((entry) => normalized.slice(entry.startIndex, entry.endIndex + 1)); + const [moved] = groups.splice(activeIndex, 1); + const adjustedTargetIndex = activeIndex < targetIndex ? targetIndex - 1 : targetIndex; + const insertIndex = position === 'before' ? adjustedTargetIndex : adjustedTargetIndex + 1; + groups.splice(insertIndex, 0, moved); + return normalizePersonality({ channels: groups.flat() }).channels; +} + +function mergeFlags(currentFlags = [], additions = []) { + return [...new Set([...currentFlags, ...additions])]; +} diff --git a/src/lib/server/db.js b/src/lib/server/db.js new file mode 100644 index 0000000..1935b46 --- /dev/null +++ b/src/lib/server/db.js @@ -0,0 +1,438 @@ +import Database from 'better-sqlite3'; +import { env } from '$env/dynamic/private'; +import { building } from '$app/environment'; + +let _db = null; + +function getDb() { + if (_db) return _db; + + const dbPath = env.DATABASE_URL ?? './dev.db'; + _db = new Database(dbPath); + + _db.pragma('journal_mode = WAL'); + _db.pragma('foreign_keys = ON'); + _db.pragma('synchronous = NORMAL'); + + migrate(_db); + runStartupTasks(_db); + return _db; +} + +function migrate(db) { + db.exec(` + CREATE TABLE IF NOT EXISTS personalities ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + prs_name TEXT, + file_name TEXT, + notes TEXT, + data BLOB NOT NULL, + manufacturer TEXT, + tags TEXT NOT NULL DEFAULT '[]', + channel_count INTEGER NOT NULL, + created_at TEXT NOT NULL, + creator_handle TEXT, + view_count INTEGER NOT NULL DEFAULT 0, + owner_token_hash TEXT NOT NULL, + deleted_at TEXT DEFAULT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_manufacturer + ON personalities(manufacturer) WHERE deleted_at IS NULL; + CREATE INDEX IF NOT EXISTS idx_created_at + ON personalities(created_at DESC) WHERE deleted_at IS NULL; + CREATE INDEX IF NOT EXISTS idx_view_count + ON personalities(view_count DESC) WHERE deleted_at IS NULL; + + CREATE VIRTUAL TABLE IF NOT EXISTS personalities_fts + USING fts5( + id UNINDEXED, + name, manufacturer, notes, tags, creator_handle, + content=personalities, content_rowid=rowid + ); + + CREATE TRIGGER IF NOT EXISTS personalities_ai + AFTER INSERT ON personalities BEGIN + INSERT INTO personalities_fts(rowid, id, name, manufacturer, notes, tags, creator_handle) + VALUES (new.rowid, new.id, new.name, new.manufacturer, new.notes, new.tags, new.creator_handle); + END; + + CREATE TRIGGER IF NOT EXISTS personalities_ad + AFTER DELETE ON personalities BEGIN + INSERT INTO personalities_fts(personalities_fts, rowid, id, name, manufacturer, notes, tags, creator_handle) + VALUES ('delete', old.rowid, old.id, old.name, old.manufacturer, old.notes, old.tags, old.creator_handle); + END; + + -- Admins + CREATE TABLE IF NOT EXISTS admins ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + created_at TEXT NOT NULL + ); + + -- Sessions + CREATE TABLE IF NOT EXISTS sessions ( + token TEXT PRIMARY KEY, + admin_id TEXT NOT NULL, + username TEXT NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + FOREIGN KEY (admin_id) REFERENCES admins(id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_sessions_expires + ON sessions(expires_at); + + -- Reports + CREATE TABLE IF NOT EXISTS reports ( + id TEXT PRIMARY KEY, + personality_id TEXT NOT NULL, + reason TEXT NOT NULL, + notes TEXT, + reporter_ip TEXT, + created_at TEXT NOT NULL, + resolved INTEGER NOT NULL DEFAULT 0 + ); + + CREATE INDEX IF NOT EXISTS idx_reports_personality + ON reports(personality_id); + -- Contact messages + CREATE TABLE IF NOT EXISTS contact_messages ( + id TEXT PRIMARY KEY, + name TEXT, + email TEXT, + subject TEXT NOT NULL, + message TEXT NOT NULL, + sender_ip TEXT, + created_at TEXT NOT NULL, + read INTEGER NOT NULL DEFAULT 0 + ); + + CREATE INDEX IF NOT EXISTS idx_messages_read + ON contact_messages(read, created_at DESC); + `); + + // Add columns to existing DBs that predate these migrations + const cols = db.prepare(`PRAGMA table_info(personalities)`).all().map(r => r.name); + if (!cols.includes('deleted_at')) { + db.exec(`ALTER TABLE personalities ADD COLUMN deleted_at TEXT DEFAULT NULL`); + } + if (!cols.includes('prs_name')) { + db.exec(`ALTER TABLE personalities ADD COLUMN prs_name TEXT DEFAULT NULL`); + } +} + +// Purge expired sessions and hard-delete soft-deleted personalities older than 60 days +function runStartupTasks(db) { + const now = new Date().toISOString(); + db.prepare(`DELETE FROM sessions WHERE expires_at < ?`).run(now); + + const cutoff = new Date(Date.now() - 60 * 24 * 60 * 60 * 1000).toISOString(); + db.prepare(`DELETE FROM personalities WHERE deleted_at IS NOT NULL AND deleted_at < ?`).run(cutoff); +} + +// ── Personality queries ────────────────────────────────────────── + +export function insertPersonality(record) { + const db = getDb(); + return db.prepare(` + INSERT INTO personalities + (id, name, prs_name, file_name, notes, data, manufacturer, tags, + channel_count, created_at, creator_handle, owner_token_hash) + VALUES + (@id, @name, @prs_name, @file_name, @notes, @data, @manufacturer, @tags, + @channel_count, @created_at, @creator_handle, @owner_token_hash) + `).run(record); +} + +export function getPersonalityById(id) { + const db = getDb(); + return db.prepare(` + SELECT id, name, prs_name, file_name, notes, manufacturer, tags, + channel_count, created_at, creator_handle, view_count, deleted_at + FROM personalities WHERE id = ? + `).get(id); +} + +export function getPersonalityDataById(id) { + const db = getDb(); + return db.prepare(`SELECT data FROM personalities WHERE id = ? AND deleted_at IS NULL`).get(id); +} + +export function getPersonalityTokenHash(id) { + const db = getDb(); + return db.prepare(`SELECT owner_token_hash FROM personalities WHERE id = ?`).get(id); +} + +export function incrementViewCount(id) { + const db = getDb(); + db.prepare(`UPDATE personalities SET view_count = view_count + 1 WHERE id = ? AND deleted_at IS NULL`).run(id); +} + +// Hard delete (owner token path) +export function deletePersonality(id) { + const db = getDb(); + db.prepare(`DELETE FROM personalities WHERE id = ?`).run(id); +} + +export function updatePersonalityMeta(id, { name, prs_name, manufacturer, notes, tags, creator_handle }) { + const db = getDb(); + db.prepare(` + UPDATE personalities SET + name = @name, + prs_name = @prs_name, + manufacturer = @manufacturer, + notes = @notes, + tags = @tags, + creator_handle = @creator_handle + WHERE id = @id + `).run({ id, name, prs_name, manufacturer, notes, tags, creator_handle }); +} + +export function replacePersonalityBinary(id, { data, prs_name, channel_count }) { + const db = getDb(); + db.prepare(` + UPDATE personalities SET + data = @data, + prs_name = @prs_name, + channel_count = @channel_count + WHERE id = @id + `).run({ id, data, prs_name, channel_count }); +} + +export function softDeletePersonality(id) { + const db = getDb(); + const now = new Date().toISOString(); + db.prepare(`UPDATE personalities SET deleted_at = ? WHERE id = ?`).run(now, id); + // Resolve all open reports for this personality + db.prepare(`UPDATE reports SET resolved = 2 WHERE personality_id = ? AND resolved = 0`).run(id); +} + +export function listPersonalities({ page = 1, limit = 24, sort = 'newest', manufacturer = '', q = '' } = {}) { + const db = getDb(); + const offset = (page - 1) * limit; + const orderBy = sort === 'popular' ? 'view_count DESC' : 'created_at DESC'; + + if (q && q.trim().length > 0) { + const ftsQuery = q.trim().split(/\s+/).map(t => `"${t.replace(/"/g, '')}"`).join(' OR '); + const whereManufacturer = manufacturer ? `AND p.manufacturer = ?` : ''; + const params = manufacturer ? [ftsQuery, manufacturer, limit, offset] : [ftsQuery, limit, offset]; + + const rows = db.prepare(` + SELECT p.id, p.name, p.prs_name, p.file_name, p.manufacturer, p.tags, + p.channel_count, p.created_at, p.creator_handle, p.view_count + FROM personalities_fts fts + JOIN personalities p ON p.rowid = fts.rowid + WHERE personalities_fts MATCH ? AND p.deleted_at IS NULL + ${whereManufacturer} + ORDER BY ${orderBy} LIMIT ? OFFSET ? + `).all(...params); + + const { total } = db.prepare(` + SELECT COUNT(*) as total + FROM personalities_fts fts + JOIN personalities p ON p.rowid = fts.rowid + WHERE personalities_fts MATCH ? AND p.deleted_at IS NULL + ${whereManufacturer} + `).get(...params.slice(0, manufacturer ? 2 : 1)); + + return { rows, total }; + } + + const where = manufacturer + ? `WHERE deleted_at IS NULL AND manufacturer = ?` + : `WHERE deleted_at IS NULL`; + const params = manufacturer ? [manufacturer, limit, offset] : [limit, offset]; + + const rows = db.prepare(` + SELECT id, name, prs_name, file_name, manufacturer, tags, + channel_count, created_at, creator_handle, view_count + FROM personalities ${where} + ORDER BY ${orderBy} LIMIT ? OFFSET ? + `).all(...params); + + const { total } = db.prepare( + `SELECT COUNT(*) as total FROM personalities ${where}` + ).get(...(manufacturer ? [manufacturer] : [])); + + return { rows, total }; +} + +export function getDistinctManufacturers() { + const db = getDb(); + return db.prepare(` + SELECT DISTINCT manufacturer FROM personalities + WHERE manufacturer IS NOT NULL AND manufacturer != '' AND deleted_at IS NULL + ORDER BY manufacturer ASC + `).all().map(r => r.manufacturer); +} + +export function getManufacturerCounts() { + const db = getDb(); + const rows = db.prepare(` + SELECT manufacturer, COUNT(*) as count + FROM personalities + WHERE manufacturer IS NOT NULL AND manufacturer != '' AND deleted_at IS NULL + GROUP BY manufacturer + ORDER BY manufacturer ASC + `).all(); + return Object.fromEntries(rows.map(r => [r.manufacturer, r.count])); +} + +// ── Admin queries ──────────────────────────────────────────────── + +export function getAdminByUsername(username) { + const db = getDb(); + return db.prepare(`SELECT * FROM admins WHERE username = ?`).get(username); +} + +export function upsertAdmin({ id, username, password_hash, created_at }) { + const db = getDb(); + return db.prepare(` + INSERT INTO admins (id, username, password_hash, created_at) + VALUES (@id, @username, @password_hash, @created_at) + ON CONFLICT(username) DO UPDATE SET password_hash = excluded.password_hash + `).run({ id, username, password_hash, created_at }); +} + +// ── Session queries ────────────────────────────────────────────── + +export function createSession({ token, admin_id, username, created_at, expires_at }) { + const db = getDb(); + return db.prepare(` + INSERT INTO sessions (token, admin_id, username, created_at, expires_at) + VALUES (@token, @admin_id, @username, @created_at, @expires_at) + `).run({ token, admin_id, username, created_at, expires_at }); +} + +export function getSession(token) { + const db = getDb(); + return db.prepare(`SELECT * FROM sessions WHERE token = ? AND expires_at > ?`) + .get(token, new Date().toISOString()); +} + +export function deleteSession(token) { + const db = getDb(); + db.prepare(`DELETE FROM sessions WHERE token = ?`).run(token); +} + +// ── Report queries ─────────────────────────────────────────────── + +export function insertReport({ id, personality_id, reason, notes, reporter_ip, created_at }) { + const db = getDb(); + return db.prepare(` + INSERT INTO reports (id, personality_id, reason, notes, reporter_ip, created_at) + VALUES (@id, @personality_id, @reason, @notes, @reporter_ip, @created_at) + `).run({ id, personality_id, reason, notes, reporter_ip, created_at }); +} + +export function getOpenReportCount() { + const db = getDb(); + return db.prepare(`SELECT COUNT(*) as count FROM reports WHERE resolved = 0`).get().count; +} + +export function listReports({ resolved = null } = {}) { + const db = getDb(); + const where = resolved === null ? '' : `WHERE r.resolved = ${resolved}`; + return db.prepare(` + SELECT r.*, p.name as personality_name, p.manufacturer, p.deleted_at + FROM reports r + LEFT JOIN personalities p ON p.id = r.personality_id + ${where} + ORDER BY r.created_at DESC + LIMIT 200 + `).all(); +} + +export function resolveReport(id, resolved = 1) { + const db = getDb(); + db.prepare(`UPDATE reports SET resolved = ? WHERE id = ?`).run(resolved, id); +} + +export function dismissAllReportsForPersonality(personality_id) { + const db = getDb(); + db.prepare(`UPDATE reports SET resolved = 1 WHERE personality_id = ? AND resolved = 0`).run(personality_id); +} + +// ── Admin dashboard stats ──────────────────────────────────────── + +export function getAdminStats() { + const db = getDb(); + const total = db.prepare(`SELECT COUNT(*) as n FROM personalities WHERE deleted_at IS NULL`).get().n; + const deleted = db.prepare(`SELECT COUNT(*) as n FROM personalities WHERE deleted_at IS NOT NULL`).get().n; + const openReports = db.prepare(`SELECT COUNT(*) as n FROM reports WHERE resolved = 0`).get().n; + const todayCutoff = new Date(Date.now() - 24*60*60*1000).toISOString(); + const today = db.prepare(`SELECT COUNT(*) as n FROM personalities WHERE created_at > ? AND deleted_at IS NULL`).get(todayCutoff).n; + const unreadMessages = db.prepare(`SELECT COUNT(*) as n FROM contact_messages WHERE read = 0`).get().n; + return { total, deleted, openReports, today, unreadMessages }; +} + +// ── Contact message queries ────────────────────────────────────── + +export function insertContactMessage({ id, name, email, subject, message, sender_ip, created_at }) { + const db = getDb(); + return db.prepare(` + INSERT INTO contact_messages (id, name, email, subject, message, sender_ip, created_at) + VALUES (@id, @name, @email, @subject, @message, @sender_ip, @created_at) + `).run({ id, name, email, subject, message, sender_ip, created_at }); +} + +export function listContactMessages({ unreadOnly = false } = {}) { + const db = getDb(); + const where = unreadOnly ? `WHERE read = 0` : ''; + return db.prepare(` + SELECT * FROM contact_messages ${where} + ORDER BY created_at DESC LIMIT 200 + `).all(); +} + +export function markMessageRead(id) { + const db = getDb(); + db.prepare(`UPDATE contact_messages SET read = 1 WHERE id = ?`).run(id); +} + +export function markAllMessagesRead() { + const db = getDb(); + db.prepare(`UPDATE contact_messages SET read = 1 WHERE read = 0`).run(); +} + +export function getUnreadMessageCount() { + const db = getDb(); + return db.prepare(`SELECT COUNT(*) as count FROM contact_messages WHERE read = 0`).get().count; +} + +export function listRecentPersonalitiesAdmin({ page = 1, limit = 25, q = '' } = {}) { + const db = getDb(); + const offset = (page - 1) * limit; + + if (q.trim()) { + const ftsQuery = q.trim().split(/\s+/).map(t => `"${t.replace(/"/g, '')}"`).join(' OR '); + const rows = db.prepare(` + SELECT p.id, p.name, p.manufacturer, p.channel_count, p.created_at, + p.creator_handle, p.view_count, p.deleted_at + FROM personalities_fts fts + JOIN personalities p ON p.rowid = fts.rowid + WHERE personalities_fts MATCH ? + ORDER BY p.created_at DESC LIMIT ? OFFSET ? + `).all(ftsQuery, limit, offset); + const { total } = db.prepare(` + SELECT COUNT(*) as total + FROM personalities_fts fts JOIN personalities p ON p.rowid = fts.rowid + WHERE personalities_fts MATCH ? + `).get(ftsQuery); + return { rows, total }; + } + + const rows = db.prepare(` + SELECT id, name, manufacturer, channel_count, created_at, + creator_handle, view_count, deleted_at + FROM personalities ORDER BY created_at DESC LIMIT ? OFFSET ? + `).all(limit, offset); + const { total } = db.prepare(`SELECT COUNT(*) as total FROM personalities`).get(); + return { rows, total }; +} + +// Prevent DB from being instantiated during build +export const db = building ? null : { getDb }; diff --git a/src/lib/server/manufacturers.js b/src/lib/server/manufacturers.js new file mode 100644 index 0000000..3e991c1 --- /dev/null +++ b/src/lib/server/manufacturers.js @@ -0,0 +1,43 @@ +// Seed list for manufacturer autocomplete suggestions. +// Users can type any value — these appear as datalist suggestions. +// ETC family brands are listed first since this is an ETC PRS tool. + +export const MANUFACTURER_SEEDS = [ + // ETC Family + 'ETC', + 'High End Systems', + 'Wybron', + + // Major moving light manufacturers + 'Martin', + 'Robe', + 'Clay Paky', + 'Chauvet Professional', + 'Elation Professional', + 'GLP', + 'Vari-Lite', + 'Ayrton', + 'ACME', + 'Claypaky', + + // LED / Blinder / Strobe + 'ADJ', + 'Chauvet DJ', + 'Blizzard Lighting', + 'CITC', + 'Acclaim Lighting', + + // Follow spots / Conventional + 'Strong International', + 'Robert Juliat', + 'Lycian', + 'Altman', + 'Strand Lighting', + + // Control / Other + 'Pathway Connectivity', + 'Philips Selecon', + 'Leviton', + 'Swisson', + 'City Theatrical', +]; diff --git a/src/lib/server/ratelimit.js b/src/lib/server/ratelimit.js new file mode 100644 index 0000000..ee3dc1f --- /dev/null +++ b/src/lib/server/ratelimit.js @@ -0,0 +1,55 @@ +import { env } from '$env/dynamic/private'; + +// In-memory store: Map +const publishStore = new Map(); +const readStore = new Map(); + +const PUBLISH_LIMIT = parseInt(env.RATE_LIMIT_PUBLISH ?? '5'); +const READ_LIMIT = parseInt(env.RATE_LIMIT_READ ?? '100'); +const PUBLISH_WINDOW_MS = 60 * 60 * 1000; // 1 hour +const READ_WINDOW_MS = 60 * 1000; // 1 minute + +function check(store, ip, limit, windowMs) { + const now = Date.now(); + const entry = store.get(ip); + + if (!entry || now - entry.windowStart > windowMs) { + store.set(ip, { count: 1, windowStart: now }); + return { allowed: true, remaining: limit - 1 }; + } + + if (entry.count >= limit) { + const retryAfter = Math.ceil((entry.windowStart + windowMs - now) / 1000); + return { allowed: false, remaining: 0, retryAfter }; + } + + entry.count += 1; + return { allowed: true, remaining: limit - entry.count }; +} + +export function checkPublishRate(ip) { + return check(publishStore, ip, PUBLISH_LIMIT, PUBLISH_WINDOW_MS); +} + +export function checkReadRate(ip) { + return check(readStore, ip, READ_LIMIT, READ_WINDOW_MS); +} + +export function getClientIp(request) { + return ( + request.headers.get('x-forwarded-for')?.split(',')[0].trim() ?? + request.headers.get('x-real-ip') ?? + '127.0.0.1' + ); +} + +// Cleanup old entries every 10 minutes to prevent memory leak +setInterval(() => { + const now = Date.now(); + for (const [ip, entry] of publishStore) { + if (now - entry.windowStart > PUBLISH_WINDOW_MS) publishStore.delete(ip); + } + for (const [ip, entry] of readStore) { + if (now - entry.windowStart > READ_WINDOW_MS) readStore.delete(ip); + } +}, 10 * 60 * 1000); diff --git a/src/lib/server/session.js b/src/lib/server/session.js new file mode 100644 index 0000000..f7791eb --- /dev/null +++ b/src/lib/server/session.js @@ -0,0 +1,40 @@ +import { nanoid } from 'nanoid'; +import { createSession, getSession, deleteSession } from './db.js'; + +export const SESSION_COOKIE = 'prs_admin_session'; +const SESSION_DAYS = 7; + +export function createAdminSession(admin) { + const token = nanoid(32); + const now = new Date(); + const expiresAt = new Date(now.getTime() + SESSION_DAYS * 24 * 60 * 60 * 1000); + + createSession({ + token, + admin_id: admin.id, + username: admin.username, + created_at: now.toISOString(), + expires_at: expiresAt.toISOString() + }); + + return { token, expiresAt }; +} + +export function verifySession(token) { + if (!token) return null; + return getSession(token) ?? null; +} + +export function destroySession(token) { + if (token) deleteSession(token); +} + +export function getSessionCookieOptions(expiresAt) { + return { + path: '/', + httpOnly: true, + sameSite: 'strict', + secure: process.env.NODE_ENV === 'production', + expires: expiresAt + }; +} diff --git a/src/lib/shared/slugify.js b/src/lib/shared/slugify.js new file mode 100644 index 0000000..a0e2ec8 --- /dev/null +++ b/src/lib/shared/slugify.js @@ -0,0 +1,12 @@ +/** + * Shared slug helper — safe for both server-side (+page.server.js) and + * client-side use. Avoids importing the full `slugify` package on the client. + */ +export function makeSlug(text) { + return String(text ?? '') + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 80) || 'personality'; +} diff --git a/src/lib/storage.js b/src/lib/storage.js new file mode 100644 index 0000000..2fd24ce --- /dev/null +++ b/src/lib/storage.js @@ -0,0 +1,110 @@ +import { browser } from '$app/environment'; +import { cryptoLikeId } from '$lib/prs'; + +export const FILES_STORAGE_KEY = 'etc-prs-ui-open-files-v2'; +export const SNAPSHOT_STORAGE_KEY = 'etc-prs-ui-history-v2'; + +function readList(key) { + if (!browser) return []; + try { + const raw = localStorage.getItem(key); + const parsed = raw ? JSON.parse(raw) : []; + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +function writeList(key, value) { + if (!browser) return; + localStorage.setItem(key, JSON.stringify(value)); +} + +export function getStoredFiles() { + return readList(FILES_STORAGE_KEY); +} + +export function saveStoredFile(personality, fileName = null, notes = null, sharedRecord = null) { + const files = getStoredFiles(); + const signature = JSON.stringify({ + name: personality.name, + channelCount: personality.channelCount, + channels: personality.channels?.map((channel) => ({ + attributeId: channel.attributeId, + home: channel.home, + displayFormatId: channel.displayFormatId, + flags: channel.flags + })) + }); + const now = new Date().toISOString(); + const existing = files.find((file) => file.signature === signature); + const record = existing ?? { + id: cryptoLikeId('file'), + createdAt: now + }; + + record.name = personality.name || '(blank)'; + record.fileName = fileName ?? record.fileName ?? null; + record.notes = notes !== null ? notes : (record.notes ?? ''); + record.channelCount = personality.channelCount ?? personality.channels?.length ?? 0; + record.signature = signature; + record.parsed = personality; + record.lastOpenedAt = now; + + // Persist share metadata if provided + if (sharedRecord) { + record.shared_id = sharedRecord.id; + record.shared_url = sharedRecord.url; + record.owner_token = sharedRecord.owner_token; + } + + writeList(FILES_STORAGE_KEY, [record, ...files.filter((file) => file.id !== record.id)].slice(0, 25)); + return record; +} + +/** + * Attach share metadata to an existing local file record by its local id. + * Called after a successful publish so the token is stored alongside the file. + */ +export function attachSharedRecord(localFileId, sharedRecord) { + if (!browser) return; + const files = getStoredFiles(); + const record = files.find((f) => f.id === localFileId); + if (!record) return; + record.shared_id = sharedRecord.id; + record.shared_url = sharedRecord.url; + record.owner_token = sharedRecord.owner_token; + writeList(FILES_STORAGE_KEY, [record, ...files.filter((f) => f.id !== localFileId)]); +} + +/** + * Remove share metadata from any local file record matching a shared_id. + * Called after a successful library delete so the record no longer shows as published. + */ +export function detachSharedRecord(sharedId) { + if (!browser) return; + const files = getStoredFiles(); + const updated = files.map(f => { + if (f.shared_id !== sharedId) return f; + const { shared_id, shared_url, owner_token, ...rest } = f; + return rest; + }); + writeList(FILES_STORAGE_KEY, updated); +} + +export function getSnapshots(fileId) { + return readList(SNAPSHOT_STORAGE_KEY).filter((snapshot) => snapshot.fileId === fileId); +} + +export function saveSnapshot(fileId, personality, label = 'Manual Snapshot') { + const all = readList(SNAPSHOT_STORAGE_KEY); + const snapshot = { + id: cryptoLikeId('snap'), + fileId, + label, + createdAt: new Date().toISOString(), + parsed: personality + }; + writeList(SNAPSHOT_STORAGE_KEY, [snapshot, ...all].slice(0, 100)); + return snapshot; +} diff --git a/src/lib/utils/clickOutside.js b/src/lib/utils/clickOutside.js new file mode 100644 index 0000000..c6464e0 --- /dev/null +++ b/src/lib/utils/clickOutside.js @@ -0,0 +1,13 @@ +export function clickOutside(node, callback) { + function handle(event) { + if (!node.contains(event.target)) callback?.(event); + } + + document.addEventListener('mousedown', handle); + + return { + destroy() { + document.removeEventListener('mousedown', handle); + } + }; +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte new file mode 100644 index 0000000..142b414 --- /dev/null +++ b/src/routes/+layout.svelte @@ -0,0 +1,9 @@ + + +
+ +
+
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte new file mode 100644 index 0000000..8f07b73 --- /dev/null +++ b/src/routes/+page.svelte @@ -0,0 +1,945 @@ + + + + ETC PRS Viewer & Editor + + + + +
+ + {#if error} +
+ +
{error}
+
+ {/if} + + {#if mode === 'root'} +
+
+ +
+ +
+

ETC PRS Viewer & Editor

+

Open a PRS file or start a blank personality.

+
+
+ + +
+
+
Stored Files
+
{previousFiles.length}
+
+
+
Snapshots
+
{snapshots.length}
+
+
+
Quick Start
+
Use top bar to open files.
+
+
+ + +
+

Previously Opened

+ {#if previousFiles.length} +
+ {#each previousFiles as file} + + {/each} +
+ {:else} +
No stored files yet.
+ {/if} +
+
+ + +
+ {:else} +
+ +
+ + +
+ + updatePersonalityName(e.currentTarget.value)} + /> +
+ {personality.name.length}/{NAME_LEN} +
+
+ + +
+ + updateDisplayName(e.currentTarget.value)} + /> + {#if !displayNameEdited && personality.name} +
+ Auto-synced from PRS name +
+ {/if} +
+ +
+
Channels
+
{totalChannelCount}
+
+
+
Entries
+
{visibleEntries.length}
+
+
+
16-bit Pairs
+
{pairCount}
+
+
+ +
+ + +
+
+ + {/if} +
+ +{#if mode !== 'root'} + (view = next)} + onOpenEditor={enterEditor} + onExitEditor={exitEditor} + onPublish={() => (showPublishModal = true)} + onAddChannel={handleAddChannel} + onSaveSnapshot={handleSaveSnapshot} + onDownload={handleDownload} + onDelete={handleDeleteFile} + onScaleUp={scaleUp} + onScaleDown={scaleDown} + onScaleReset={scaleReset} + /> + +
+
+ {#if view === 'cards'} + + {:else} + + {/if} +
+
+{/if} + + + +{#if showNewFileModal} + +{/if} + +{#if showPublishModal} + (showPublishModal = false)} + /> +{/if} + +{#if publishResult} + (publishResult = null)} + /> +{/if} + +{#if showClearModal} + (showClearModal = false)} + /> +{/if} diff --git a/src/routes/about/+page.svelte b/src/routes/about/+page.svelte new file mode 100644 index 0000000..afb91e6 --- /dev/null +++ b/src/routes/about/+page.svelte @@ -0,0 +1,176 @@ + + About — ETC PRS Editor + + + +
+
+
+
+
+
ETC PRS
+
About
+
+
+
+ ← App + Library +
+
+ +
+ + +
+
+ What is this? +
+

+ ETC PRS
Viewer & Editor +

+

+ A web-based tool for inspecting, editing, and sharing ETC Expression + fixture personality files — right in your browser, with no installation required. +

+
+ + +
+ + +
+

+ Features +

+ +
+ {#each features as f} +
+
{f.icon}
+
{f.title}
+

{f.body}

+
+ {/each} +
+
+ + +
+ + +
+

+ 16-bit Channel Handling +

+

+ 16-bit channel pairs are detected strictly from the binary flag in the PRS file — + no heuristics, no guessing based on channel numbering. The leader channel carries + the 0x04 flag bit, + and the following channel is treated as its pair. +

+

+ Home and display format values follow ETC's own storage convention — stored in the + second (follower) channel — so exported files are binary-compatible with ETC's + Personality Editor. +

+
+ + +
+ + +
+

+ Privacy & Data +

+
+ {#each privacyPoints as point} +
+ + {point} +
+ {/each} +
+
+ + +
+
+
Ethical Disclosures
+
+ AI usage, reverse engineering methodology, and affiliation statement. +
+
+ + Read disclosures → + +
+ +
+ + diff --git a/src/routes/admin/+layout.server.js b/src/routes/admin/+layout.server.js new file mode 100644 index 0000000..9eaa6d2 --- /dev/null +++ b/src/routes/admin/+layout.server.js @@ -0,0 +1,18 @@ +import { redirect } from '@sveltejs/kit'; +import { verifySession, SESSION_COOKIE } from '$lib/server/session.js'; + +export async function load({ cookies, url }) { + // Login and logout routes handle their own auth + if (url.pathname === '/admin/login' || url.pathname === '/admin/logout') { + return {}; + } + + const token = cookies.get(SESSION_COOKIE); + const session = verifySession(token); + + if (!session) { + throw redirect(303, `/admin/login?redirect=${encodeURIComponent(url.pathname)}`); + } + + return { admin: { username: session.username } }; +} diff --git a/src/routes/admin/+layout.svelte b/src/routes/admin/+layout.svelte new file mode 100644 index 0000000..0589ce6 --- /dev/null +++ b/src/routes/admin/+layout.svelte @@ -0,0 +1,43 @@ + + + + Admin — ETC PRS + + +{#if data.admin} + +
+
+
+
+
+
ETC PRS
+
Admin Console
+
+
+
+
+ {data.admin.username} +
+ ← App +
+ +
+
+
+ +
+ +
+{:else} + + +{/if} diff --git a/src/routes/admin/+page.server.js b/src/routes/admin/+page.server.js new file mode 100644 index 0000000..38e714e --- /dev/null +++ b/src/routes/admin/+page.server.js @@ -0,0 +1,32 @@ +import { getAdminStats, listReports, listRecentPersonalitiesAdmin, listContactMessages } from '$lib/server/db.js'; + +export async function load({ url }) { + const reportFilter = url.searchParams.get('reports') ?? 'open'; + const messageFilter = url.searchParams.get('messages') ?? 'unread'; + const adminQ = url.searchParams.get('q') ?? ''; + const adminPage = Math.max(1, parseInt(url.searchParams.get('page') ?? '1')); + + const stats = getAdminStats(); + + const resolved = reportFilter === 'all' ? null : reportFilter === 'dismissed' ? 1 : 0; + const reports = listReports({ resolved }); + + const messages = listContactMessages({ unreadOnly: messageFilter === 'unread' }); + + const { rows: personalities, total: totalPersonalities } = listRecentPersonalitiesAdmin({ + page: adminPage, limit: 25, q: adminQ + }); + + return { + stats, + reports, + reportFilter, + messages, + messageFilter, + personalities, + totalPersonalities, + adminPage, + adminQ, + totalPages: Math.ceil(totalPersonalities / 25) + }; +} diff --git a/src/routes/admin/+page.svelte b/src/routes/admin/+page.svelte new file mode 100644 index 0000000..07ed56d --- /dev/null +++ b/src/routes/admin/+page.svelte @@ -0,0 +1,647 @@ + + + +
+ {#each [ + { label:'Total Personalities', value: data.stats.total, color:'var(--amber)' }, + { label:'Published Today', value: data.stats.today, color:'var(--green)' }, + { label:'Open Reports', value: data.stats.openReports, color: data.stats.openReports > 0 ? 'var(--red)' : 'var(--text2)' }, + { label:'Unread Messages', value: data.stats.unreadMessages, color: data.stats.unreadMessages > 0 ? 'var(--cyan)' : 'var(--text2)' }, + { label:'Soft Deleted', value: data.stats.deleted, color:'var(--text3)' }, + ] as stat} +
+
{stat.label}
+
+ {stat.value} +
+
+ {/each} +
+ + +
+
+
+ Messages + {#if data.stats.unreadMessages > 0} + {data.stats.unreadMessages} unread + {/if} +
+
+ {#each [['unread','Unread'],['all','All']] as [val, label]} + + {/each} + {#if data.stats.unreadMessages > 0} + + {/if} +
+
+ + {#if data.messages.length === 0} +
+ No messages in this view. +
+ {:else} + {#each data.messages as msg} +
+
+
+
+ {#if !msg.read} + + {/if} + + {msg.subject} + + + {msg.name || 'Anonymous'} + + {#if msg.email} + + {msg.email} + + {/if} +
+
+ {msg.message} +
+
+ {formatTime(msg.created_at)} +
+
+ {#if !msg.read} + + {/if} +
+
+ {/each} + {/if} +
+ + +
+
+
+ Reports + {#if data.stats.openReports > 0} + {data.stats.openReports} open + {/if} +
+
+ {#each [['open','Open'],['dismissed','Dismissed'],['all','All']] as [val, label]} + + {/each} +
+
+ + {#if data.reports.length === 0} +
+ No reports in this view. +
+ {:else} + {#each data.reports as report} +
+
+
+
+ + {report.personality_name ?? report.personality_id} + + {#if report.manufacturer} + {report.manufacturer} + {/if} + {#if report.deleted_at} + + DELETED + + {/if} +
+
+ {reasonLabels[report.reason] ?? report.reason} +
+ {#if report.notes} +
"{report.notes}"
+ {/if} +
+ Reported {formatTime(report.created_at)} + {#if report.resolved === 1} · Dismissed + {:else if report.resolved === 2} · Removed + {/if} +
+
+ {#if report.resolved === 0} +
+ + {#if !report.deleted_at} + + {/if} +
+ {/if} +
+
+ {/each} + {/if} +
+ + +
+
+
+ All Personalities + + {data.totalPersonalities} total + +
+ +
+ +
+ + + + {#each ['Fixture','Manufacturer','Ch','By','Published','Status',''] as h} + + {/each} + + + + {#each data.personalities as p} + + { if (editingId !== p.id) e.currentTarget.style.background='var(--raised)'; }} + on:mouseleave={(e) => { if (editingId !== p.id) e.currentTarget.style.background=''; }}> + + + + + + + + + + + {#if editingId === p.id} + + + + {/if} + {/each} + +
+ {h} +
+ + {p.name} + + {#if p.prs_name && p.prs_name !== p.name} +
PRS: {p.prs_name}
+ {/if} +
{p.manufacturer || '—'}{p.channel_count}{p.creator_handle || '—'}{formatDate(p.created_at)} + {#if p.deleted_at} + + Deleted {formatDate(p.deleted_at)} + + {:else} + + Live + + {/if} + +
+ {#if !p.deleted_at} + {#if editingId === p.id} + + {:else} + + + {/if} + {/if} +
+
+
+
+ Editing: {p.name} +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + + {#if editError} + {editError} + {/if} +
+ + +
+
Replace Binary (.prs file)
+ + {#if replaceId !== p.id || !replacePreview} +
+ + + Current: {p.channel_count}ch · PRS name: {p.prs_name || '(none)'} + +
+ {/if} + + + {#if replaceId === p.id && replacePreview} +
+
+ Change Summary +
+
+ Field + Current + Incoming + + PRS Name + {replacePreview.current.prs_name || '—'} + + {replacePreview.incoming.prs_name || '—'} + {#if replacePreview.incoming.prs_name !== replacePreview.current.prs_name} + ← changed + {/if} + + + Channels + {replacePreview.current.channel_count} + + {replacePreview.incoming.channel_count} + {#if replacePreview.incoming.channel_count !== replacePreview.current.channel_count} + ← changed + {/if} + +
+
+ +
+ + + + {#if replaceError} + {replaceError} + {/if} +
+ {/if} + + {#if replaceError && !replacePreview} +
{replaceError}
+ {/if} +
+
+
+
+ + + + + + {#if data.totalPages > 1} +
+ {#each Array.from({length: data.totalPages}, (_, i) => i+1) as p} + + {/each} +
+ {/if} +
diff --git a/src/routes/admin/login/+page.server.js b/src/routes/admin/login/+page.server.js new file mode 100644 index 0000000..f94a238 --- /dev/null +++ b/src/routes/admin/login/+page.server.js @@ -0,0 +1,49 @@ +import { redirect, fail } from '@sveltejs/kit'; +import bcrypt from 'bcryptjs'; +import { getAdminByUsername } from '$lib/server/db.js'; +import { createAdminSession, getSessionCookieOptions, SESSION_COOKIE, verifySession } from '$lib/server/session.js'; +import { checkPublishRate, getClientIp } from '$lib/server/ratelimit.js'; + +export async function load({ cookies }) { + const token = cookies.get(SESSION_COOKIE); + if (verifySession(token)) throw redirect(303, '/admin'); + return {}; +} + +export const actions = { + default: async ({ request, cookies }) => { + // Rate limit login attempts + const ip = getClientIp(request); + const rate = checkPublishRate(ip); + if (!rate.allowed) { + return fail(429, { error: `Too many attempts. Try again soon.` }); + } + + const form = await request.formData(); + const username = String(form.get('username') ?? '').trim(); + const password = String(form.get('password') ?? ''); + + if (!username || !password) { + return fail(400, { error: 'Username and password are required.', username }); + } + + const admin = getAdminByUsername(username); + + // Always run bcrypt to prevent timing attacks + const hash = admin?.password_hash ?? '$2a$12$invalidhashtopreventtimingattack000000000000000000'; + const valid = await bcrypt.compare(password, hash); + + if (!admin || !valid) { + return fail(401, { error: 'Invalid username or password.', username }); + } + + const { token, expiresAt } = createAdminSession(admin); + cookies.set(SESSION_COOKIE, token, getSessionCookieOptions(expiresAt)); + + const redirectTo = String(request.headers.get('referer') ?? '').includes('redirect=') + ? new URL(request.headers.get('referer')).searchParams.get('redirect') ?? '/admin' + : '/admin'; + + throw redirect(303, redirectTo); + } +}; diff --git a/src/routes/admin/login/+page.svelte b/src/routes/admin/login/+page.svelte new file mode 100644 index 0000000..2353cd4 --- /dev/null +++ b/src/routes/admin/login/+page.svelte @@ -0,0 +1,60 @@ + + +Admin Login — ETC PRS + +
+
+ + +
+
+
+
+ ETC PRS Admin +
+
+ Sign in to continue +
+
+
+ + {#if form?.error} +
+ {form.error} +
+ {/if} + +
+
+ + +
+
+ + +
+ +
+
+
diff --git a/src/routes/admin/logout/+page.server.js b/src/routes/admin/logout/+page.server.js new file mode 100644 index 0000000..51547c0 --- /dev/null +++ b/src/routes/admin/logout/+page.server.js @@ -0,0 +1,11 @@ +import { redirect } from '@sveltejs/kit'; +import { destroySession, SESSION_COOKIE } from '$lib/server/session.js'; + +export const actions = { + default: async ({ cookies }) => { + const token = cookies.get(SESSION_COOKIE); + destroySession(token); + cookies.delete(SESSION_COOKIE, { path: '/' }); + throw redirect(303, '/admin/login'); + } +}; diff --git a/src/routes/admin/logout/+page.svelte b/src/routes/admin/logout/+page.svelte new file mode 100644 index 0000000..94699c9 --- /dev/null +++ b/src/routes/admin/logout/+page.svelte @@ -0,0 +1 @@ + diff --git a/src/routes/api/admin/delete-personality/+server.js b/src/routes/api/admin/delete-personality/+server.js new file mode 100644 index 0000000..3559012 --- /dev/null +++ b/src/routes/api/admin/delete-personality/+server.js @@ -0,0 +1,18 @@ +import { json, error } from '@sveltejs/kit'; +import { softDeletePersonality, getPersonalityById } from '$lib/server/db.js'; +import { verifySession, SESSION_COOKIE } from '$lib/server/session.js'; + +export async function POST({ request, cookies }) { + const session = verifySession(cookies.get(SESSION_COOKIE)); + if (!session) throw error(401, 'Unauthorized'); + + const { id } = await request.json().catch(() => ({})); + if (!id) throw error(400, 'Missing personality id'); + + const record = getPersonalityById(id); + if (!record) throw error(404, 'Personality not found'); + if (record.deleted_at) throw error(400, 'Already deleted'); + + softDeletePersonality(id); + return json({ success: true }); +} diff --git a/src/routes/api/admin/dismiss-report/+server.js b/src/routes/api/admin/dismiss-report/+server.js new file mode 100644 index 0000000..cc29cc6 --- /dev/null +++ b/src/routes/api/admin/dismiss-report/+server.js @@ -0,0 +1,14 @@ +import { json, error } from '@sveltejs/kit'; +import { resolveReport } from '$lib/server/db.js'; +import { verifySession, SESSION_COOKIE } from '$lib/server/session.js'; + +export async function POST({ request, cookies }) { + const session = verifySession(cookies.get(SESSION_COOKIE)); + if (!session) throw error(401, 'Unauthorized'); + + const { id } = await request.json().catch(() => ({})); + if (!id) throw error(400, 'Missing report id'); + + resolveReport(id, 1); + return json({ success: true }); +} diff --git a/src/routes/api/admin/edit-personality/+server.js b/src/routes/api/admin/edit-personality/+server.js new file mode 100644 index 0000000..0a3a31f --- /dev/null +++ b/src/routes/api/admin/edit-personality/+server.js @@ -0,0 +1,32 @@ +import { json, error } from '@sveltejs/kit'; +import { getPersonalityById, updatePersonalityMeta } from '$lib/server/db.js'; +import { verifySession, SESSION_COOKIE } from '$lib/server/session.js'; + +export async function PATCH({ request, cookies }) { + const session = verifySession(cookies.get(SESSION_COOKIE)); + if (!session) throw error(401, 'Unauthorized'); + + const body = await request.json().catch(() => null); + if (!body?.id) throw error(400, 'Missing id'); + + const record = getPersonalityById(body.id); + if (!record) throw error(404, 'Personality not found'); + if (record.deleted_at) throw error(400, 'Cannot edit a deleted personality'); + + const name = typeof body.name === 'string' ? body.name.trim().slice(0, 120) : record.name; + const prs_name = typeof body.prs_name === 'string' ? body.prs_name.trim().slice(0, 12) : record.prs_name; + const manufacturer = typeof body.manufacturer === 'string' ? body.manufacturer.trim().slice(0, 128) : record.manufacturer; + const notes = typeof body.notes === 'string' ? body.notes.trim().slice(0, 1000) : record.notes; + const creator_handle = typeof body.creator_handle === 'string' ? body.creator_handle.trim().slice(0, 64) : record.creator_handle; + + const rawTags = Array.isArray(body.tags) ? body.tags : JSON.parse(record.tags ?? '[]'); + const tags = JSON.stringify( + rawTags.slice(0, 10).map(t => String(t).trim().toLowerCase().slice(0, 32)).filter(Boolean) + ); + + if (!name) throw error(400, 'Library name is required'); + + updatePersonalityMeta(body.id, { name, prs_name, manufacturer, notes, tags, creator_handle }); + + return json({ success: true }); +} diff --git a/src/routes/api/admin/mark-read/+server.js b/src/routes/api/admin/mark-read/+server.js new file mode 100644 index 0000000..6eff13c --- /dev/null +++ b/src/routes/api/admin/mark-read/+server.js @@ -0,0 +1,20 @@ +import { json, error } from '@sveltejs/kit'; +import { markMessageRead, markAllMessagesRead } from '$lib/server/db.js'; +import { verifySession, SESSION_COOKIE } from '$lib/server/session.js'; + +export async function POST({ request, cookies }) { + const session = verifySession(cookies.get(SESSION_COOKIE)); + if (!session) throw error(401, 'Unauthorized'); + + const { id, all } = await request.json().catch(() => ({})); + + if (all) { + markAllMessagesRead(); + } else if (id) { + markMessageRead(id); + } else { + throw error(400, 'Missing id or all flag'); + } + + return json({ success: true }); +} diff --git a/src/routes/api/admin/messages/+server.js b/src/routes/api/admin/messages/+server.js new file mode 100644 index 0000000..5e8a673 --- /dev/null +++ b/src/routes/api/admin/messages/+server.js @@ -0,0 +1,13 @@ +import { json, error } from '@sveltejs/kit'; +import { listContactMessages } from '$lib/server/db.js'; +import { verifySession, SESSION_COOKIE } from '$lib/server/session.js'; + +export async function GET({ cookies, url }) { + const session = verifySession(cookies.get(SESSION_COOKIE)); + if (!session) throw error(401, 'Unauthorized'); + + const unreadOnly = url.searchParams.get('unread') === '1'; + const messages = listContactMessages({ unreadOnly }); + + return json({ messages }); +} diff --git a/src/routes/api/admin/replace-binary/+server.js b/src/routes/api/admin/replace-binary/+server.js new file mode 100644 index 0000000..2eae4eb --- /dev/null +++ b/src/routes/api/admin/replace-binary/+server.js @@ -0,0 +1,61 @@ +import { json, error } from '@sveltejs/kit'; +import { getPersonalityById, replacePersonalityBinary } from '$lib/server/db.js'; +import { verifySession, SESSION_COOKIE } from '$lib/server/session.js'; +import { FILE_SIZE } from '$lib/prs.js'; + +const NAME_OFFSET = 0x0e; +const NAME_LEN = 12; +const CH_COUNT_OFFSET = 0x0d; + +function readPrsName(bytes) { + let name = ''; + for (let i = 0; i < NAME_LEN; i++) { + if (bytes[NAME_OFFSET + i] === 0) break; + name += String.fromCharCode(bytes[NAME_OFFSET + i]); + } + return name.trim(); +} + +export async function POST({ request, cookies }) { + const session = verifySession(cookies.get(SESSION_COOKIE)); + if (!session) throw error(401, 'Unauthorized'); + + const body = await request.json().catch(() => null); + if (!body?.id || !body?.data) throw error(400, 'Missing id or data'); + + const record = getPersonalityById(body.id); + if (!record) throw error(404, 'Personality not found'); + if (record.deleted_at) throw error(400, 'Cannot replace binary of a deleted personality'); + + const bytes = new Uint8Array(body.data); + if (bytes.length !== FILE_SIZE) { + throw error(400, `Invalid file size: expected ${FILE_SIZE} bytes, got ${bytes.length}`); + } + + const newPrsName = readPrsName(bytes); + const newChannelCount = bytes[CH_COUNT_OFFSET]; + + // Preview mode — return diff without saving + if (body.preview) { + return json({ + preview: true, + current: { + prs_name: record.prs_name ?? '', + channel_count: record.channel_count + }, + incoming: { + prs_name: newPrsName, + channel_count: newChannelCount + } + }); + } + + // Commit — replace the binary + replacePersonalityBinary(body.id, { + data: Buffer.from(bytes), + prs_name: newPrsName, + channel_count: newChannelCount + }); + + return json({ success: true, prs_name: newPrsName, channel_count: newChannelCount }); +} diff --git a/src/routes/api/admin/report-count/+server.js b/src/routes/api/admin/report-count/+server.js new file mode 100644 index 0000000..fcb1bf9 --- /dev/null +++ b/src/routes/api/admin/report-count/+server.js @@ -0,0 +1,15 @@ +import { json } from '@sveltejs/kit'; +import { getOpenReportCount, getUnreadMessageCount } from '$lib/server/db.js'; +import { verifySession, SESSION_COOKIE } from '$lib/server/session.js'; + +export async function GET({ cookies }) { + const session = verifySession(cookies.get(SESSION_COOKIE)); + if (!session) { + return json({ count: null }); + } + + const reports = getOpenReportCount(); + const messages = getUnreadMessageCount(); + + return json({ count: reports + messages, reports, messages }); +} diff --git a/src/routes/api/contact/+server.js b/src/routes/api/contact/+server.js new file mode 100644 index 0000000..7321f50 --- /dev/null +++ b/src/routes/api/contact/+server.js @@ -0,0 +1,55 @@ +import { json, error } from '@sveltejs/kit'; +import { nanoid } from 'nanoid'; +import { insertContactMessage } from '$lib/server/db.js'; +import { checkPublishRate, getClientIp } from '$lib/server/ratelimit.js'; + +const MIN_ELAPSED_MS = 4000; // reject if form submitted in under 4 seconds +const MAX_NAME_LEN = 100; +const MAX_EMAIL_LEN = 254; +const MAX_SUBJECT_LEN = 200; +const MAX_MESSAGE_LEN = 3000; + +const SUBJECT_OPTIONS = [ + 'General question', + 'Bug report', + 'Feature request', + 'Library content issue', + 'Other', +]; + +export async function POST({ request }) { + const ip = getClientIp(request); + const rate = checkPublishRate(ip); + if (!rate.allowed) throw error(429, 'Too many submissions. Please try again later.'); + + let body; + try { body = await request.json(); } + catch { throw error(400, 'Invalid request.'); } + + const { name, email, subject, message, _hp, _ts } = body; + + // Honeypot — if filled in, silently accept but don't store + if (_hp) return json({ success: true }, { status: 201 }); + + // Timing check — reject suspiciously fast submissions + const elapsed = Date.now() - Number(_ts ?? 0); + if (elapsed < MIN_ELAPSED_MS) return json({ success: true }, { status: 201 }); + + // Validate required fields + if (!subject || !SUBJECT_OPTIONS.includes(subject)) throw error(400, 'Please select a subject.'); + if (!message || typeof message !== 'string' || message.trim().length < 10) { + throw error(400, 'Message must be at least 10 characters.'); + } + + insertContactMessage({ + id: nanoid(10), + name: typeof name === 'string' ? name.trim().slice(0, MAX_NAME_LEN) : null, + email: typeof email === 'string' ? email.trim().slice(0, MAX_EMAIL_LEN) : null, + subject: subject.slice(0, MAX_SUBJECT_LEN), + message: message.trim().slice(0, MAX_MESSAGE_LEN), + sender_ip: ip, + created_at: new Date().toISOString(), + }); + + return json({ success: true }, { status: 201 }); +} diff --git a/src/routes/api/delete/+server.js b/src/routes/api/delete/+server.js new file mode 100644 index 0000000..868df3e --- /dev/null +++ b/src/routes/api/delete/+server.js @@ -0,0 +1,38 @@ +import { json, error } from '@sveltejs/kit'; +import bcrypt from 'bcryptjs'; +import { getPersonalityTokenHash, deletePersonality, getPersonalityById } from '$lib/server/db.js'; +import { checkPublishRate, getClientIp } from '$lib/server/ratelimit.js'; + +export async function DELETE({ request }) { + // Reuse publish rate limit for destructive actions + const ip = getClientIp(request); + const rate = checkPublishRate(ip); + if (!rate.allowed) { + throw error(429, `Too many requests. Try again in ${rate.retryAfter} seconds.`); + } + + let body; + try { + body = await request.json(); + } catch { + throw error(400, 'Invalid JSON body.'); + } + + const { id, owner_token } = body; + + if (!id || typeof id !== 'string') throw error(400, 'Missing id.'); + if (!owner_token || typeof owner_token !== 'string') throw error(400, 'Missing owner_token.'); + + const meta = getPersonalityById(id); + if (!meta) throw error(404, 'Personality not found.'); + + const tokenRow = getPersonalityTokenHash(id); + if (!tokenRow) throw error(404, 'Personality not found.'); + + const valid = await bcrypt.compare(owner_token, tokenRow.owner_token_hash); + if (!valid) throw error(403, 'Invalid owner token.'); + + deletePersonality(id); + + return json({ success: true }); +} diff --git a/src/routes/api/library/+server.js b/src/routes/api/library/+server.js new file mode 100644 index 0000000..6c6ca7f --- /dev/null +++ b/src/routes/api/library/+server.js @@ -0,0 +1,37 @@ +import { json } from '@sveltejs/kit'; +import { listPersonalities } from '$lib/server/db.js'; +import { checkReadRate, getClientIp } from '$lib/server/ratelimit.js'; + +export async function GET({ request, url }) { + const ip = getClientIp(request); + const rate = checkReadRate(ip); + if (!rate.allowed) { + return json({ error: 'Rate limit exceeded.' }, { status: 429 }); + } + + const q = url.searchParams.get('q') ?? ''; + const manufacturer = url.searchParams.get('manufacturer') ?? ''; + const sort = url.searchParams.get('sort') ?? 'newest'; + const page = Math.max(1, parseInt(url.searchParams.get('page') ?? '1')); + const limit = Math.min(48, Math.max(1, parseInt(url.searchParams.get('limit') ?? '24'))); + + const { rows, total } = listPersonalities({ page, limit, sort, manufacturer, q }); + + // Parse tags JSON for each row + const items = rows.map(row => ({ + ...row, + tags: tryParseJson(row.tags, []) + })); + + return json({ + items, + total, + page, + pages: Math.ceil(total / limit), + limit + }); +} + +function tryParseJson(str, fallback) { + try { return JSON.parse(str); } catch { return fallback; } +} diff --git a/src/routes/api/manufacturers/+server.js b/src/routes/api/manufacturers/+server.js new file mode 100644 index 0000000..5384ab6 --- /dev/null +++ b/src/routes/api/manufacturers/+server.js @@ -0,0 +1,14 @@ +import { json } from '@sveltejs/kit'; +import { getDistinctManufacturers } from '$lib/server/db.js'; +import { MANUFACTURER_SEEDS } from '$lib/server/manufacturers.js'; + +export async function GET() { + const fromDb = getDistinctManufacturers(); + + // Merge DB values with seed list, deduplicate, sort + const all = [...new Set([...MANUFACTURER_SEEDS, ...fromDb])].sort((a, b) => + a.localeCompare(b) + ); + + return json({ manufacturers: all }); +} diff --git a/src/routes/api/personality/[id]/+server.js b/src/routes/api/personality/[id]/+server.js new file mode 100644 index 0000000..a176374 --- /dev/null +++ b/src/routes/api/personality/[id]/+server.js @@ -0,0 +1,25 @@ +import { json, error } from '@sveltejs/kit'; +import { getPersonalityById, incrementViewCount } from '$lib/server/db.js'; +import { checkReadRate, getClientIp } from '$lib/server/ratelimit.js'; + +export async function GET({ request, params }) { + const ip = getClientIp(request); + const rate = checkReadRate(ip); + if (!rate.allowed) { + return json({ error: 'Rate limit exceeded.' }, { status: 429 }); + } + + const record = getPersonalityById(params.id); + if (!record) throw error(404, 'Personality not found.'); + + incrementViewCount(params.id); + + return json({ + ...record, + tags: tryParseJson(record.tags, []) + }); +} + +function tryParseJson(str, fallback) { + try { return JSON.parse(str); } catch { return fallback; } +} diff --git a/src/routes/api/personality/[id]/download/+server.js b/src/routes/api/personality/[id]/download/+server.js new file mode 100644 index 0000000..7e5cc20 --- /dev/null +++ b/src/routes/api/personality/[id]/download/+server.js @@ -0,0 +1,30 @@ +import { error } from '@sveltejs/kit'; +import { getPersonalityById, getPersonalityDataById } from '$lib/server/db.js'; +import { checkReadRate, getClientIp } from '$lib/server/ratelimit.js'; + +export async function GET({ request, params }) { + const ip = getClientIp(request); + const rate = checkReadRate(ip); + if (!rate.allowed) { + return new Response('Rate limit exceeded.', { status: 429 }); + } + + const meta = getPersonalityById(params.id); + if (!meta) throw error(404, 'Personality not found.'); + + const row = getPersonalityDataById(params.id); + if (!row) throw error(404, 'Personality data not found.'); + + const safeName = (meta.file_name ?? meta.name ?? 'personality') + .replace(/[^a-zA-Z0-9_\-. ]/g, '_') + .replace(/\.prs$/i, ''); + + return new Response(row.data, { + status: 200, + headers: { + 'Content-Type': 'application/octet-stream', + 'Content-Disposition': `attachment; filename="${safeName}.prs"`, + 'Content-Length': String(row.data.length) + } + }); +} diff --git a/src/routes/api/report/+server.js b/src/routes/api/report/+server.js new file mode 100644 index 0000000..0d83e89 --- /dev/null +++ b/src/routes/api/report/+server.js @@ -0,0 +1,35 @@ +import { json, error } from '@sveltejs/kit'; +import { nanoid } from 'nanoid'; +import { insertReport, getPersonalityById } from '$lib/server/db.js'; +import { checkPublishRate, getClientIp } from '$lib/server/ratelimit.js'; + +const VALID_REASONS = ['incorrect-data', 'duplicate', 'inappropriate', 'spam', 'other']; + +export async function POST({ request }) { + const ip = getClientIp(request); + const rate = checkPublishRate(ip); + if (!rate.allowed) throw error(429, `Too many reports. Try again later.`); + + let body; + try { body = await request.json(); } + catch { throw error(400, 'Invalid JSON'); } + + const { personality_id, reason, notes } = body; + + if (!personality_id || typeof personality_id !== 'string') throw error(400, 'Missing personality_id'); + if (!VALID_REASONS.includes(reason)) throw error(400, 'Invalid reason'); + + const record = getPersonalityById(personality_id); + if (!record || record.deleted_at) throw error(404, 'Personality not found'); + + insertReport({ + id: nanoid(10), + personality_id, + reason, + notes: typeof notes === 'string' ? notes.trim().slice(0, 500) : null, + reporter_ip: ip, + created_at: new Date().toISOString() + }); + + return json({ success: true }, { status: 201 }); +} diff --git a/src/routes/api/share/+server.js b/src/routes/api/share/+server.js new file mode 100644 index 0000000..ecb3797 --- /dev/null +++ b/src/routes/api/share/+server.js @@ -0,0 +1,98 @@ +import { json, error } from '@sveltejs/kit'; +import bcrypt from 'bcryptjs'; +import { nanoid } from 'nanoid'; +import { insertPersonality } from '$lib/server/db.js'; +import { checkPublishRate, getClientIp } from '$lib/server/ratelimit.js'; +import { makeSlug } from '$lib/shared/slugify.js'; +import { FILE_SIZE } from '$lib/prs.js'; +import { env } from '$env/dynamic/public'; + +const MAX_HANDLE_LEN = 64; +const MAX_NOTES_LEN = 1000; +const MAX_TAGS = 10; +const MAX_TAG_LEN = 32; + +export async function POST({ request }) { + // Rate limiting + const ip = getClientIp(request); + const rate = checkPublishRate(ip); + if (!rate.allowed) { + throw error(429, `Too many uploads. Try again in ${rate.retryAfter} seconds.`); + } + + let body; + try { + body = await request.json(); + } catch { + throw error(400, 'Invalid JSON body.'); + } + + const { data, name, library_name, file_name, notes, manufacturer, tags, creator_handle } = body; + + // Validate binary data + if (!data || !Array.isArray(data)) { + throw error(400, 'Missing or invalid PRS data.'); + } + const bytes = new Uint8Array(data); + if (bytes.length !== FILE_SIZE) { + throw error(400, `PRS file must be exactly ${FILE_SIZE} bytes (got ${bytes.length}).`); + } + + // prs_name is the raw 12-char binary name; library_name is the full display name + if (!name || typeof name !== 'string') { + throw error(400, 'Missing fixture name.'); + } + + const prsName = name.trim().slice(0, 12); + const displayName = typeof library_name === 'string' && library_name.trim() + ? library_name.trim().slice(0, 120) + : prsName; // fall back to PRS name if no display name provided + + // Sanitize optional fields + const cleanNotes = typeof notes === 'string' ? notes.slice(0, MAX_NOTES_LEN) : ''; + const cleanHandle = typeof creator_handle === 'string' + ? creator_handle.trim().slice(0, MAX_HANDLE_LEN) + : ''; + const cleanManufacturer = typeof manufacturer === 'string' + ? manufacturer.trim().slice(0, 128) + : ''; + + // Validate tags + const cleanTags = Array.isArray(tags) + ? tags.slice(0, MAX_TAGS).map(t => String(t).trim().toLowerCase().slice(0, MAX_TAG_LEN)).filter(Boolean) + : []; + + // Channel count from the binary + const channelCount = bytes[0x0d]; + + // Generate ID and owner token + const id = nanoid(10); + const slug = makeSlug(displayName); + const rawToken = nanoid(32); + const tokenHash = await bcrypt.hash(rawToken, 10); + const createdAt = new Date().toISOString(); + + insertPersonality({ + id, + name: displayName, + prs_name: prsName, + file_name: typeof file_name === 'string' ? file_name.slice(0, 256) : null, + notes: cleanNotes, + data: Buffer.from(bytes), + manufacturer: cleanManufacturer, + tags: JSON.stringify(cleanTags), + channel_count: channelCount, + created_at: createdAt, + creator_handle: cleanHandle || null, + owner_token_hash: tokenHash + }); + + const baseUrl = env.PUBLIC_BASE_URL ?? ''; + + return json({ + id, + slug, + url: `${baseUrl}/p/${id}/${slug}`, + owner_token: rawToken + }, { status: 201 }); +} diff --git a/src/routes/contact/+page.svelte b/src/routes/contact/+page.svelte new file mode 100644 index 0000000..51b704d --- /dev/null +++ b/src/routes/contact/+page.svelte @@ -0,0 +1,209 @@ + + + + Contact — ETC PRS Editor + + + +
+
+
+
+
+
ETC PRS
+
Contact
+
+
+
+ ← App +
+
+ +
+ +
+ Get in touch +
+

+ Contact +

+

+ Have a question, found a bug, or want to suggest a feature? We'd love to hear from you. +

+ + {#if submitted} +
+
+
Message Sent
+

+ Thanks for reaching out. We'll get back to you if a reply is needed. +

+
+ {:else} +
+ + + + + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ {#each SUBJECT_OPTIONS as opt} + + {/each} +
+
+ + +
+ + +
+ {message.length} / 3000 +
+
+ + {#if errorMsg} +
+ {errorMsg} +
+ {/if} + + + +
+ {/if} + +
diff --git a/src/routes/disclosures/+page.svelte b/src/routes/disclosures/+page.svelte new file mode 100644 index 0000000..9bb96c1 --- /dev/null +++ b/src/routes/disclosures/+page.svelte @@ -0,0 +1,176 @@ + + Disclosures — ETC PRS Editor + + + +
+
+
+
+
+
ETC PRS
+
Disclosures
+
+
+
+ ← About + App +
+
+ +
+ +
+ Transparency +
+

+ Disclosures +

+ + +
+
+
+

+ Affiliation & Endorsement +

+
+
+

+ This project is not affiliated with, sponsored by, + or endorsed by ETC (Electronic Theatre Controls, Inc.) in any way. +

+

+ ETC, Expression, and related product names are trademarks of Electronic Theatre Controls, Inc. + All trademarks are the property of their respective owners. +

+

+ This tool is an independent, community-built project intended for educational purposes, + interoperability, and supporting legacy lighting systems. +

+
+
+ + +
+
+
+

+ Reverse Engineering +

+
+
+

+ The .prs file format + used by ETC Expression lighting consoles is not publicly documented in full detail. + In order to build this tool, the format was analyzed through: +

+
    + {#each reverseEngineeringPoints as point} +
  • + + {point} +
  • + {/each} +
+

+ No proprietary source code was accessed or used. The reverse engineering was limited + strictly to understanding the file structure for the purpose of interoperability. +

+
+
+ + +
+
+
+

+ AI-Assisted Development +

+
+
+

+ Significant portions of this application were developed with the assistance of + AI language models, including: +

+
    + {#each aiPoints as point} +
  • + + {point} +
  • + {/each} +
+

+ All AI-generated output was reviewed, tested, and refined by a human developer + before inclusion. The application logic — particularly the binary parser, 16-bit + channel handling, and PRS export — was carefully verified against real fixture files + and ETC's own tooling. +

+

+ We believe in being transparent about how software is built. AI assistance does not + diminish the care taken to make this tool accurate and reliable for the lighting community. +

+
+
+ + +
+
+
+

+ Intent & Scope +

+
+
+

+ This project exists to serve the lighting community. It is intended for: +

+
    + {#each intentPoints as point} +
  • + + {point} +
  • + {/each} +
+
+
+ +
+ + diff --git a/src/routes/library/+page.server.js b/src/routes/library/+page.server.js new file mode 100644 index 0000000..f44ef88 --- /dev/null +++ b/src/routes/library/+page.server.js @@ -0,0 +1,43 @@ +import { listPersonalities, getDistinctManufacturers, getManufacturerCounts } from '$lib/server/db.js'; +import { MANUFACTURER_SEEDS } from '$lib/server/manufacturers.js'; + +const VALID_LIMITS = [12, 24, 48, 96]; + +export async function load({ url }) { + const q = url.searchParams.get('q') ?? ''; + const manufacturer = url.searchParams.get('manufacturer') ?? ''; + const sort = url.searchParams.get('sort') ?? 'newest'; + const page = Math.max(1, parseInt(url.searchParams.get('page') ?? '1')); + const view = url.searchParams.get('view') ?? 'cards'; + const limitParam = parseInt(url.searchParams.get('limit') ?? '24'); + const limit = VALID_LIMITS.includes(limitParam) ? limitParam : 24; + + const { rows, total } = listPersonalities({ page, limit, sort, manufacturer, q }); + + const items = rows.map(row => ({ + ...row, + tags: tryParseJson(row.tags, []) + })); + + const dbManufacturers = getDistinctManufacturers(); + const manufacturerCounts = getManufacturerCounts(); + const manufacturers = [...new Set([...MANUFACTURER_SEEDS, ...dbManufacturers])].sort(); + + return { + items, + total, + limit, + pages: Math.ceil(total / limit), + page, + q, + manufacturer, + sort, + view, + manufacturers, + manufacturerCounts + }; +} + +function tryParseJson(str, fallback) { + try { return JSON.parse(str); } catch { return fallback; } +} diff --git a/src/routes/library/+page.svelte b/src/routes/library/+page.svelte new file mode 100644 index 0000000..f562a8c --- /dev/null +++ b/src/routes/library/+page.svelte @@ -0,0 +1,421 @@ + + + + Library — ETC PRS Editor + + + goto('/')} + onOpenStoredFile={openStoredFile} + onOpenSnapshot={openSnapshot} + onGoHome={() => goto('/')} +/> + + +
+
+ + +
+
+ + +
+ +
+ + +
+ + + + + + + + +
+ + +
+ {data.total} + + result{data.total !== 1 ? 's' : ''} + +
+ + +
+ + +
+ +
+ +
+
+ +
+
+ + +
+ + {#if data.items.length === 0} +
+
No personalities found
+
Try a different search or clear the filters.
+
+ + {:else if data.view === 'table'} + +
+
+ + + + {#each ['Fixture', 'Manufacturer', 'Channels', 'Tags', 'By', 'Date', 'Views', ''] as h} + + {/each} + + + + {#each data.items as item} + e.currentTarget.style.background = 'var(--raised)'} + on:mouseleave={(e) => e.currentTarget.style.background = ''}> + + + + + + + + + + {/each} + +
+ {h} +
+ + {item.name} + + {#if item.prs_name && item.prs_name !== item.name} +
PRS: {item.prs_name}
+ {/if} + {#if item.file_name} +
{item.file_name}
+ {/if} +
+ {item.manufacturer || '—'} + + {item.channel_count} + +
+ {#each item.tags as tag} + {tag} + {/each} +
+
+ {item.creator_handle || '—'} + + {formatDate(item.created_at)} + + {item.view_count} + +
+ + + + +
+
+
+
+ + {:else} + +
+ {#each data.items as item} +
+ +
+
+ {item.manufacturer || 'Unknown'} +
+ + {item.name} + + {#if item.prs_name && item.prs_name !== item.name} +
PRS: {item.prs_name}
+ {/if} + {#if item.file_name} +
+ {item.file_name} +
+ {/if} +
+ + +
+ +
+
+ {item.channel_count} +
+
CH
+
+ + + {#if item.tags.length} +
+ {#each item.tags as tag} + {tag} + {/each} +
+ {/if} + + +
+ {#if item.creator_handle}
By {item.creator_handle}
{/if} +
{formatDate(item.created_at)} · {item.view_count} views
+
+
+ + +
+ + + + +
+
+ {/each} +
+ {/if} + + + {#if data.pages > 1} +
+ + + {#each Array.from({ length: data.pages }, (_, i) => i + 1) as p} + {#if data.pages <= 7 || Math.abs(p - data.page) <= 2 || p === 1 || p === data.pages} + + {:else if Math.abs(p - data.page) === 3} + + {/if} + {/each} + + +
+ {/if} + +
diff --git a/src/routes/p/[id]/[slug]/+page.server.js b/src/routes/p/[id]/[slug]/+page.server.js new file mode 100644 index 0000000..17af0c1 --- /dev/null +++ b/src/routes/p/[id]/[slug]/+page.server.js @@ -0,0 +1,24 @@ +import { error } from '@sveltejs/kit'; +import { getPersonalityById } from '$lib/server/db.js'; + +export async function load({ params }) { + const record = getPersonalityById(params.id); + if (!record) throw error(404, 'Personality not found.'); + + // Don't increment view count for deleted personalities + if (!record.deleted_at) { + const { incrementViewCount } = await import('$lib/server/db.js'); + incrementViewCount(params.id); + } + + return { + personality: { + ...record, + tags: tryParseJson(record.tags, []) + } + }; +} + +function tryParseJson(str, fallback) { + try { return JSON.parse(str); } catch { return fallback; } +} diff --git a/src/routes/p/[id]/[slug]/+page.svelte b/src/routes/p/[id]/[slug]/+page.svelte new file mode 100644 index 0000000..4ad721b --- /dev/null +++ b/src/routes/p/[id]/[slug]/+page.svelte @@ -0,0 +1,339 @@ + + + + {p.name} — ETC PRS Library + + + + +
+
+
+
+
+
ETC PRS
+
Personality Library
+
+
+
+ ← Library + App +
+
+ +
+ + +
+ + +
+ +
+ + {#if p.manufacturer} +
+ {p.manufacturer} +
+ {/if} +

+ {p.name} +

+ {#if p.prs_name && p.prs_name !== p.name} +
+ PRS name: + {p.prs_name} +
+ {/if} + + +
+
+
+ {p.channel_count} +
+
Channels
+
+
+ + {p.view_count} views +
+ {#if p.creator_handle} +
By {p.creator_handle}
+ {/if} +
+ {formatDate(p.created_at)} +
+ {#if p.file_name} +
{p.file_name}
+ {/if} +
+ + + {#if p.tags.length} +
+ {#each p.tags as tag} + {tag} + {/each} +
+ {/if} + + + {#if p.notes} +
+ {p.notes} +
+ {/if} + + +
+ {#if !p.deleted_at} + + + Download .prs + + {/if} + + + + + + {#if !p.deleted_at} + + {/if} +
+ + + {#if p.deleted_at} +
+ + This personality has been removed. +
+ It is no longer available in the library. If you believe this was in error, + please contact us. +
+ {/if} + + + {#if showDeleteForm} +
+ {#if deleteSuccess} +
+ Deleted. Redirecting to library… +
+ {:else} +
Delete this personality
+

+ This action is permanent and cannot be undone. + {#if isOwner} + Your owner token has been detected from your browser's local storage and pre-filled below. + {:else} + Enter your owner token to confirm. + {/if} +

+
+ {#if isOwner} + + {:else} + e.key === 'Enter' && handleDelete()} + /> + {/if} + +
+ {#if deleteError} +
{deleteError}
+ {/if} + {/if} +
+ {/if} +
+
+ + + {#if !p.deleted_at} + {#if visibleEntries.length > 0} + +
+
{visibleEntries.length} entries · {p.channel_count} channels
+
+ +
+ +
+
+ + {#if view === 'cards'} + + {:else} + + {/if} + {:else} +
+
Loading channel data…
+
+ {/if} + {/if} + +
+ +{#if showReportModal} + showReportModal = false} + onCancel={() => showReportModal = false} + /> +{/if} diff --git a/svelte.config.js b/svelte.config.js new file mode 100644 index 0000000..da4f336 --- /dev/null +++ b/svelte.config.js @@ -0,0 +1,12 @@ +import adapter from '@sveltejs/adapter-node'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + kit: { + adapter: adapter({ + out: 'build' + }) + } +}; + +export default config; diff --git a/tailwind.config.cjs b/tailwind.config.cjs new file mode 100644 index 0000000..cf46b87 --- /dev/null +++ b/tailwind.config.cjs @@ -0,0 +1,40 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: ['./src/**/*.{html,js,svelte,ts}'], + theme: { + extend: { + fontFamily: { + mono: ['"DM Mono"', '"Fira Mono"', 'monospace'], + sans: ['"Barlow"', 'sans-serif'], + cond: ['"Barlow Condensed"', 'sans-serif'], + }, + colors: { + console: { + bg: '#0d0f0e', + surface: '#131713', + raised: '#191e19', + border: '#263026', + border2: '#2f3b2f', + amber: '#e8930a', + amber2: '#f5b730', + amberglow:'rgba(232,147,10,0.15)', + cyan: '#2dd4c8', + cyandim: 'rgba(45,212,200,0.12)', + magenta: '#d946a8', + magentadim:'rgba(217,70,168,0.12)', + green: '#4ade80', + red: '#f87171', + text: '#d4dbd4', + text2: '#7a9478', + text3: '#3d5c3d', + } + }, + boxShadow: { + soft: '0 4px 16px rgba(0,0,0,0.5)', + amber: '0 0 12px rgba(232,147,10,0.3)', + console: 'inset 0 1px 0 rgba(255,255,255,0.04)', + } + } + }, + plugins: [] +}; diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000..3406f32 --- /dev/null +++ b/vite.config.js @@ -0,0 +1,6 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [sveltekit()] +});