From eaaadd39e4528a891ffdf5769919b8e54d8d9691 Mon Sep 17 00:00:00 2001 From: RaineAllDay Date: Wed, 18 Mar 2026 03:06:27 -0600 Subject: [PATCH] initial deployment v1.0 --- .env.example | 7 + .gitignore | 8 + BINARY_LAYOUT.md | 41 + DEPLOY.md | 372 +++ README.md | 124 + etc-prs-ui-gpt.zip | Bin 0 -> 30082 bytes package-lock.json | 2963 +++++++++++++++++ package.json | 29 + postcss.config.cjs | 6 + scripts/create-admin.js | 72 + scripts/deploy.sh | 389 +++ scripts/import-personalities.js | 273 ++ src/app.css | 209 ++ src/app.html | 11 + src/lib/components/ChannelCardGrid.svelte | 163 + src/lib/components/ChannelTable.svelte | 165 + src/lib/components/ClearDataModal.svelte | 108 + src/lib/components/DropdownMenu.svelte | 65 + src/lib/components/Footer.svelte | 57 + src/lib/components/GlobalMenu.svelte | 262 ++ src/lib/components/PublishModal.svelte | 155 + src/lib/components/PublishSuccessModal.svelte | 115 + src/lib/components/ReportModal.svelte | 144 + src/lib/components/SecondaryBar.svelte | 133 + src/lib/components/TagInput.svelte | 63 + src/lib/components/Toast.svelte | 24 + src/lib/prs.js | 320 ++ src/lib/server/db.js | 438 +++ src/lib/server/manufacturers.js | 43 + src/lib/server/ratelimit.js | 55 + src/lib/server/session.js | 40 + src/lib/shared/slugify.js | 12 + src/lib/storage.js | 110 + src/lib/utils/clickOutside.js | 13 + src/routes/+layout.svelte | 9 + src/routes/+page.svelte | 945 ++++++ src/routes/about/+page.svelte | 176 + src/routes/admin/+layout.server.js | 18 + src/routes/admin/+layout.svelte | 43 + src/routes/admin/+page.server.js | 32 + src/routes/admin/+page.svelte | 647 ++++ src/routes/admin/login/+page.server.js | 49 + src/routes/admin/login/+page.svelte | 60 + src/routes/admin/logout/+page.server.js | 11 + src/routes/admin/logout/+page.svelte | 1 + .../api/admin/delete-personality/+server.js | 18 + .../api/admin/dismiss-report/+server.js | 14 + .../api/admin/edit-personality/+server.js | 32 + src/routes/api/admin/mark-read/+server.js | 20 + src/routes/api/admin/messages/+server.js | 13 + .../api/admin/replace-binary/+server.js | 61 + src/routes/api/admin/report-count/+server.js | 15 + src/routes/api/contact/+server.js | 55 + src/routes/api/delete/+server.js | 38 + src/routes/api/library/+server.js | 37 + src/routes/api/manufacturers/+server.js | 14 + src/routes/api/personality/[id]/+server.js | 25 + .../api/personality/[id]/download/+server.js | 30 + src/routes/api/report/+server.js | 35 + src/routes/api/share/+server.js | 98 + src/routes/contact/+page.svelte | 209 ++ src/routes/disclosures/+page.svelte | 176 + src/routes/library/+page.server.js | 43 + src/routes/library/+page.svelte | 421 +++ src/routes/p/[id]/[slug]/+page.server.js | 24 + src/routes/p/[id]/[slug]/+page.svelte | 339 ++ svelte.config.js | 12 + tailwind.config.cjs | 40 + vite.config.js | 6 + 69 files changed, 10755 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 BINARY_LAYOUT.md create mode 100644 DEPLOY.md create mode 100644 README.md create mode 100644 etc-prs-ui-gpt.zip create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 postcss.config.cjs create mode 100644 scripts/create-admin.js create mode 100644 scripts/deploy.sh create mode 100644 scripts/import-personalities.js create mode 100644 src/app.css create mode 100644 src/app.html create mode 100644 src/lib/components/ChannelCardGrid.svelte create mode 100644 src/lib/components/ChannelTable.svelte create mode 100644 src/lib/components/ClearDataModal.svelte create mode 100644 src/lib/components/DropdownMenu.svelte create mode 100644 src/lib/components/Footer.svelte create mode 100644 src/lib/components/GlobalMenu.svelte create mode 100644 src/lib/components/PublishModal.svelte create mode 100644 src/lib/components/PublishSuccessModal.svelte create mode 100644 src/lib/components/ReportModal.svelte create mode 100644 src/lib/components/SecondaryBar.svelte create mode 100644 src/lib/components/TagInput.svelte create mode 100644 src/lib/components/Toast.svelte create mode 100644 src/lib/prs.js create mode 100644 src/lib/server/db.js create mode 100644 src/lib/server/manufacturers.js create mode 100644 src/lib/server/ratelimit.js create mode 100644 src/lib/server/session.js create mode 100644 src/lib/shared/slugify.js create mode 100644 src/lib/storage.js create mode 100644 src/lib/utils/clickOutside.js create mode 100644 src/routes/+layout.svelte create mode 100644 src/routes/+page.svelte create mode 100644 src/routes/about/+page.svelte create mode 100644 src/routes/admin/+layout.server.js create mode 100644 src/routes/admin/+layout.svelte create mode 100644 src/routes/admin/+page.server.js create mode 100644 src/routes/admin/+page.svelte create mode 100644 src/routes/admin/login/+page.server.js create mode 100644 src/routes/admin/login/+page.svelte create mode 100644 src/routes/admin/logout/+page.server.js create mode 100644 src/routes/admin/logout/+page.svelte create mode 100644 src/routes/api/admin/delete-personality/+server.js create mode 100644 src/routes/api/admin/dismiss-report/+server.js create mode 100644 src/routes/api/admin/edit-personality/+server.js create mode 100644 src/routes/api/admin/mark-read/+server.js create mode 100644 src/routes/api/admin/messages/+server.js create mode 100644 src/routes/api/admin/replace-binary/+server.js create mode 100644 src/routes/api/admin/report-count/+server.js create mode 100644 src/routes/api/contact/+server.js create mode 100644 src/routes/api/delete/+server.js create mode 100644 src/routes/api/library/+server.js create mode 100644 src/routes/api/manufacturers/+server.js create mode 100644 src/routes/api/personality/[id]/+server.js create mode 100644 src/routes/api/personality/[id]/download/+server.js create mode 100644 src/routes/api/report/+server.js create mode 100644 src/routes/api/share/+server.js create mode 100644 src/routes/contact/+page.svelte create mode 100644 src/routes/disclosures/+page.svelte create mode 100644 src/routes/library/+page.server.js create mode 100644 src/routes/library/+page.svelte create mode 100644 src/routes/p/[id]/[slug]/+page.server.js create mode 100644 src/routes/p/[id]/[slug]/+page.svelte create mode 100644 svelte.config.js create mode 100644 tailwind.config.cjs create mode 100644 vite.config.js 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 0000000000000000000000000000000000000000..5c3713d3ce78dbd4ac1063e5c176162bfe146542 GIT binary patch literal 30082 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≫Nn(b>(4s=>w0k5;DHdGaCj3vbHx{0=E$k+Tg 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" + }, + "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()] +});