initial deployment v1.0
This commit is contained in:
7
.env.example
Normal file
7
.env.example
Normal file
@@ -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 <username> <password>
|
||||
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
.env
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
node_modules/
|
||||
build/
|
||||
.svelte-kit/
|
||||
.DS_Store
|
||||
41
BINARY_LAYOUT.md
Normal file
41
BINARY_LAYOUT.md
Normal file
@@ -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
|
||||
372
DEPLOY.md
Normal file
372
DEPLOY.md
Normal file
@@ -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
|
||||
```
|
||||
124
README.md
Normal file
124
README.md
Normal file
@@ -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
|
||||
BIN
etc-prs-ui-gpt.zip
Normal file
BIN
etc-prs-ui-gpt.zip
Normal file
Binary file not shown.
2963
package-lock.json
generated
Normal file
2963
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
package.json
Normal file
29
package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
6
postcss.config.cjs
Normal file
6
postcss.config.cjs
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
};
|
||||
72
scripts/create-admin.js
Normal file
72
scripts/create-admin.js
Normal file
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Create or update an admin user.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/create-admin.js <username> <password>
|
||||
*
|
||||
* 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 <username> <password>');
|
||||
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();
|
||||
389
scripts/deploy.sh
Normal file
389
scripts/deploy.sh
Normal file
@@ -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" <<EOF
|
||||
DATABASE_URL=${DATA_DIR}/personalities.db
|
||||
RATE_LIMIT_PUBLISH=${RATE_PUBLISH}
|
||||
RATE_LIMIT_READ=${RATE_READ}
|
||||
PUBLIC_BASE_URL=https://${DOMAIN}
|
||||
EOF
|
||||
|
||||
chown "${APP_USER}:${APP_USER}" "$ENV_FILE"
|
||||
chmod 600 "$ENV_FILE"
|
||||
success ".env written to ${ENV_FILE}"
|
||||
|
||||
# ── Step 5: Install deps & build ──────────────────────────────────────────────
|
||||
header "Step 5 / 9 — Install & Build"
|
||||
|
||||
info "Installing npm dependencies (this may take a minute)…"
|
||||
sudo -u "$APP_USER" npm --prefix "$APP_DIR" install --quiet
|
||||
|
||||
info "Building SvelteKit app…"
|
||||
sudo -u "$APP_USER" npm --prefix "$APP_DIR" run build
|
||||
|
||||
success "Build complete"
|
||||
|
||||
# ── Step 6: PM2 ───────────────────────────────────────────────────────────────
|
||||
header "Step 6 / 9 — PM2 Process Manager"
|
||||
|
||||
ECOSYSTEM="${APP_DIR}/ecosystem.config.cjs"
|
||||
cat > "$ECOSYSTEM" <<EOF
|
||||
module.exports = {
|
||||
apps: [{
|
||||
name: 'etc-prs',
|
||||
script: '${APP_DIR}/build/index.js',
|
||||
cwd: '${APP_DIR}',
|
||||
user: '${APP_USER}',
|
||||
env: {
|
||||
NODE_ENV: 'production',
|
||||
PORT: '3000',
|
||||
HOST: '127.0.0.1',
|
||||
},
|
||||
error_file: '${LOG_DIR}/error.log',
|
||||
out_file: '${LOG_DIR}/out.log',
|
||||
log_date_format: 'YYYY-MM-DD HH:mm:ss',
|
||||
restart_delay: 3000,
|
||||
max_restarts: 10,
|
||||
}]
|
||||
};
|
||||
EOF
|
||||
|
||||
chown "${APP_USER}:${APP_USER}" "$ECOSYSTEM"
|
||||
|
||||
info "Starting app with PM2…"
|
||||
sudo -u "$APP_USER" pm2 start "$ECOSYSTEM"
|
||||
sudo -u "$APP_USER" pm2 save
|
||||
|
||||
info "Configuring PM2 to start on boot…"
|
||||
# Capture the startup command PM2 emits and run it
|
||||
PM2_STARTUP=$(sudo -u "$APP_USER" pm2 startup systemd -u "$APP_USER" --hp /opt/etc-prs 2>&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" <<EOF
|
||||
server {
|
||||
listen 80;
|
||||
server_name ${DOMAIN} www.${DOMAIN};
|
||||
|
||||
# Gzip
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_types text/plain text/css application/json application/javascript
|
||||
text/xml application/xml application/xml+rss text/javascript;
|
||||
|
||||
# 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;
|
||||
proxy_read_timeout 30s;
|
||||
client_max_body_size 2M;
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
# Remove default site, enable ours
|
||||
rm -f /etc/nginx/sites-enabled/default
|
||||
ln -sf "$NGINX_CONF" /etc/nginx/sites-enabled/etc-prs
|
||||
|
||||
nginx -t || error "Nginx config test failed — check ${NGINX_CONF}"
|
||||
systemctl reload nginx
|
||||
success "Nginx configured for ${DOMAIN}"
|
||||
|
||||
# ── Step 8: SSL ───────────────────────────────────────────────────────────────
|
||||
header "Step 8 / 9 — SSL Certificate (Let's Encrypt)"
|
||||
|
||||
info "Requesting certificate for ${DOMAIN} and www.${DOMAIN}…"
|
||||
info "DNS must be pointing at this server's IP for this to succeed."
|
||||
echo ""
|
||||
|
||||
if certbot --nginx \
|
||||
--non-interactive \
|
||||
--agree-tos \
|
||||
--email "$SSL_EMAIL" \
|
||||
--domains "${DOMAIN},www.${DOMAIN}" \
|
||||
--redirect; then
|
||||
success "SSL certificate issued and Nginx updated"
|
||||
info "Verifying auto-renewal…"
|
||||
certbot renew --dry-run --quiet && success "Auto-renewal check passed"
|
||||
else
|
||||
warn "Certbot failed — HTTP is still working but HTTPS is not configured."
|
||||
warn "Once your DNS is pointing here, run:"
|
||||
warn " sudo certbot --nginx -d ${DOMAIN} -d www.${DOMAIN} --email ${SSL_EMAIL} --agree-tos"
|
||||
fi
|
||||
|
||||
# ── Step 9: Backup cron + admin user ─────────────────────────────────────────
|
||||
header "Step 9 / 9 — Backups, Firewall & Admin Account"
|
||||
|
||||
# Backup script
|
||||
cat > "$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 <user> <pass>"
|
||||
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 ""
|
||||
273
scripts/import-personalities.js
Normal file
273
scripts/import-personalities.js
Normal file
@@ -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 <json-file> <prs-dir> [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 <json-file> <prs-dir> [--dry-run] [--skip-existing] [--creator <name>]');
|
||||
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();
|
||||
209
src/app.css
Normal file
209
src/app.css
Normal file
@@ -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); }
|
||||
11
src/app.html
Normal file
11
src/app.html
Normal file
@@ -0,0 +1,11 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
163
src/lib/components/ChannelCardGrid.svelte
Normal file
163
src/lib/components/ChannelCardGrid.svelte
Normal file
@@ -0,0 +1,163 @@
|
||||
<script>
|
||||
import { GripVertical, Trash2 } from 'lucide-svelte';
|
||||
import { DISPLAY_FORMATS } from '$lib/prs';
|
||||
|
||||
export let entries = [];
|
||||
export let editable = false;
|
||||
export let attributes = [];
|
||||
export let draggingKey = null;
|
||||
export let dropTarget = null;
|
||||
export let onDragStart = () => {};
|
||||
export let onDrop = () => {};
|
||||
export let onDragEnd = () => {};
|
||||
export let onUpdate = () => {};
|
||||
export let onDelete = () => {};
|
||||
export let onTogglePair = () => {};
|
||||
|
||||
function attrClass(attrName) {
|
||||
const n = (attrName || '').toLowerCase();
|
||||
if (n === 'intens') return 'attr-intensity';
|
||||
if (['pan','tilt','pan ro','tilt ro'].includes(n)) return 'attr-movement';
|
||||
if (['color','color2','cyan','magenta','yellow','clrfnc'].includes(n)) return 'attr-color';
|
||||
if (n.startsWith('beam') || ['zoom','focus','iris','frost','prism'].includes(n)) return 'attr-beam';
|
||||
if (['strobe','speed','speed2','contrl','contr2'].includes(n)) return 'attr-control';
|
||||
return 'attr-none';
|
||||
}
|
||||
|
||||
function confirmDelete(entry) {
|
||||
const label = entry.isPair
|
||||
? `channels ${entry.leader.channel}/${entry.follower.channel} (16-bit pair)`
|
||||
: `channel ${entry.leader.channel}`;
|
||||
if (confirm(`Delete ${label}? This cannot be undone.`)) onDelete(entry.key);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="grid gap-2" style="grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));" role="list">
|
||||
{#each entries as entry (entry.key)}
|
||||
{@const ac = attrClass(entry.leader.attribute)}
|
||||
<div
|
||||
data-entry-key={entry.key}
|
||||
class="panel channel-card-accent {ac} transition"
|
||||
style="
|
||||
{draggingKey === entry.key ? 'opacity:0.35;' : ''}
|
||||
{dropTarget?.key === entry.key && dropTarget?.position === 'before' ? 'box-shadow:0 -3px 0 var(--amber),inset 0 1px 0 rgba(255,255,255,0.03);' : ''}
|
||||
{dropTarget?.key === entry.key && dropTarget?.position === 'after' ? 'box-shadow:0 3px 0 var(--amber),inset 0 1px 0 rgba(255,255,255,0.03);' : ''}
|
||||
"
|
||||
role="listitem"
|
||||
aria-grabbed={editable ? draggingKey === entry.key : undefined}
|
||||
on:dragover|preventDefault={(event) => onDrop(entry.key, event.clientY, false)}
|
||||
on:drop={(event) => onDrop(entry.key, event.clientY, true)}
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-start justify-between gap-2 px-4 pb-1.5 pt-2.5" style="border-bottom:1px solid var(--border);">
|
||||
<div class="min-w-0">
|
||||
<!-- Channel number — clearly readable -->
|
||||
<div style="font-family:'DM Mono',monospace;font-size:1em;font-weight:500;color:var(--text2);margin-bottom:4px;letter-spacing:0.06em;">
|
||||
{entry.isPair ? `CH ${entry.leader.channel} / ${entry.follower.channel}` : `CH ${entry.leader.channel}`}
|
||||
{#if entry.isPair}
|
||||
<span style="margin-left:6px;padding:2px 6px;border-radius:2px;background:var(--cyan-dim);border:1px solid rgba(45,212,200,0.25);color:var(--cyan);font-size:10px;font-family:'Barlow Condensed',sans-serif;font-weight:700;letter-spacing:0.1em;text-transform:uppercase;vertical-align:middle;">16-BIT</span>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Attribute — the hero, big and clear -->
|
||||
{#if editable}
|
||||
<select
|
||||
class="select"
|
||||
style="font-family:'Barlow Condensed',sans-serif;font-weight:700;font-size:1.1em;letter-spacing:0.04em;padding:3px 8px;height:auto;"
|
||||
value={entry.leader.attributeId ?? 0}
|
||||
on:change={(e) => onUpdate(entry.key, { attributeId: Number(e.currentTarget.value) })}
|
||||
>
|
||||
{#each attributes as attribute, index}
|
||||
<option value={index}>{attribute}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{:else}
|
||||
<div style="font-family:'Barlow Condensed',sans-serif;font-weight:700;font-size:1.25em;letter-spacing:0.04em;color:var(--text);line-height:1.1;">{entry.leader.attribute || 'Not Used'}</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if editable}
|
||||
<div class="flex shrink-0 items-center gap-1.5">
|
||||
<button class="btn" style="padding:6px;cursor:grab;background:transparent;border-color:var(--border);" type="button" draggable="true"
|
||||
aria-label={`Drag to reorder channel ${entry.leader.channel}`}
|
||||
on:dragstart={() => onDragStart(entry.key)} on:dragend={onDragEnd}>
|
||||
<GripVertical size={15} />
|
||||
</button>
|
||||
<button class="btn btn-danger" style="padding:6px;" type="button" on:click={() => confirmDelete(entry)}>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="grid grid-cols-2 gap-x-2 gap-y-2.5 px-4 py-2.5">
|
||||
|
||||
<!-- Home -->
|
||||
<div>
|
||||
<div class="label mb-1.5">Home</div>
|
||||
{#if editable}
|
||||
<input class="input" type="number" min="0" max="255" value={entry.home}
|
||||
on:change={(e) => onUpdate(entry.key, { home: Number(e.currentTarget.value) })} />
|
||||
{:else}
|
||||
<div style="font-family:'DM Mono',monospace;font-size:1.05em;font-weight:500;color:var(--text);">{entry.home}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Display -->
|
||||
<div>
|
||||
<div class="label mb-1.5">Display</div>
|
||||
{#if editable}
|
||||
<select class="select" value={entry.displayFormatId}
|
||||
on:change={(e) => onUpdate(entry.key, { displayFormatId: Number(e.currentTarget.value) })}>
|
||||
{#each DISPLAY_FORMATS as format, index}
|
||||
<option value={index}>{format}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{:else}
|
||||
<div style="font-family:'DM Mono',monospace;font-size:1.05em;font-weight:500;color:var(--text);">{entry.displayFormat}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Flags full-width -->
|
||||
<div class="col-span-2">
|
||||
<div class="label mb-2">Flags</div>
|
||||
{#if editable}
|
||||
<div class="grid grid-cols-2 gap-1.5">
|
||||
{#each ['Independent', 'LTP', 'Flipped'] as flag}
|
||||
<label style="display:flex;align-items:center;gap:8px;padding:7px 10px;border-radius:3px;border:1px solid var(--border2);background:var(--bg);cursor:pointer;">
|
||||
<input type="checkbox" style="accent-color:var(--amber);cursor:pointer;width:14px;height:14px;"
|
||||
checked={entry.leader.flags.includes(flag)}
|
||||
on:change={(e) => {
|
||||
const next = e.currentTarget.checked
|
||||
? [...new Set([...entry.leader.flags, flag])]
|
||||
: entry.leader.flags.filter((item) => item !== flag);
|
||||
onUpdate(entry.key, { flags: entry.isPair ? [...new Set([...next, '16-bit'])] : next.filter((item) => item !== '16-bit') });
|
||||
}}
|
||||
/>
|
||||
<span style="font-family:'Barlow Condensed',sans-serif;font-size:0.75em;font-weight:600;letter-spacing:0.06em;text-transform:uppercase;color:var(--text2);">{flag}</span>
|
||||
</label>
|
||||
{/each}
|
||||
<label style="display:flex;align-items:center;gap:8px;padding:7px 10px;border-radius:3px;border:1px solid rgba(45,212,200,0.2);background:var(--cyan-dim);cursor:pointer;grid-column:1/-1;">
|
||||
<input type="checkbox" style="accent-color:var(--cyan);cursor:pointer;width:14px;height:14px;"
|
||||
checked={entry.isPair}
|
||||
on:change={(e) => onTogglePair(entry.key, e.currentTarget.checked)}
|
||||
/>
|
||||
<span style="font-family:'Barlow Condensed',sans-serif;font-size:0.75em;font-weight:600;letter-spacing:0.06em;text-transform:uppercase;color:var(--cyan);">16-bit Pair</span>
|
||||
</label>
|
||||
</div>
|
||||
{:else}
|
||||
<div style="display:flex;flex-wrap:wrap;gap:5px;">
|
||||
{#if entry.leader.flags.filter(f => f !== '16-bit').length}
|
||||
{#each entry.leader.flags.filter(f => f !== '16-bit') as flag}
|
||||
<span class="badge">{flag}</span>
|
||||
{/each}
|
||||
{:else}
|
||||
<span class="badge" style="color:var(--text3);">None</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
165
src/lib/components/ChannelTable.svelte
Normal file
165
src/lib/components/ChannelTable.svelte
Normal file
@@ -0,0 +1,165 @@
|
||||
<script>
|
||||
import { GripVertical, Trash2 } from 'lucide-svelte';
|
||||
import { DISPLAY_FORMATS } from '$lib/prs';
|
||||
|
||||
export let entries = [];
|
||||
export let editable = false;
|
||||
export let attributes = [];
|
||||
export let draggingKey = null;
|
||||
export let dropTarget = null;
|
||||
export let onDragStart = () => {};
|
||||
export let onDrop = () => {};
|
||||
export let onDragEnd = () => {};
|
||||
export let onUpdate = () => {};
|
||||
export let onDelete = () => {};
|
||||
export let onTogglePair = () => {};
|
||||
|
||||
function attrColor(attrName) {
|
||||
const n = (attrName || '').toLowerCase();
|
||||
if (n === 'intens') return 'var(--amber)';
|
||||
if (['pan','tilt','pan ro','tilt ro'].includes(n)) return 'var(--cyan)';
|
||||
if (['color','color2','cyan','magenta','yellow','clrfnc'].includes(n)) return 'var(--magenta)';
|
||||
if (n.startsWith('beam') || ['zoom','focus','iris','frost','prism'].includes(n)) return '#a78bfa';
|
||||
return 'var(--text2)';
|
||||
}
|
||||
|
||||
function confirmDelete(entry) {
|
||||
const label = entry.isPair
|
||||
? `channels ${entry.leader.channel}/${entry.follower.channel} (16-bit pair)`
|
||||
: `channel ${entry.leader.channel}`;
|
||||
if (confirm(`Delete ${label}? This cannot be undone.`)) onDelete(entry.key);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="panel" style="overflow:hidden;">
|
||||
<div style="overflow-x:auto;">
|
||||
<table style="width:100%;border-collapse:collapse;">
|
||||
<thead>
|
||||
<tr style="background:var(--raised);">
|
||||
<th style="padding:10px 16px;text-align:left;font-family:'Barlow Condensed',sans-serif;font-size:0.65em;font-weight:700;letter-spacing:0.12em;text-transform:uppercase;color:var(--text2);border-bottom:1px solid var(--border);white-space:nowrap;">Ch</th>
|
||||
<th style="padding:10px 16px;text-align:left;font-family:'Barlow Condensed',sans-serif;font-size:0.65em;font-weight:700;letter-spacing:0.12em;text-transform:uppercase;color:var(--text2);border-bottom:1px solid var(--border);">Attribute</th>
|
||||
<th style="padding:10px 16px;text-align:left;font-family:'Barlow Condensed',sans-serif;font-size:0.65em;font-weight:700;letter-spacing:0.12em;text-transform:uppercase;color:var(--text2);border-bottom:1px solid var(--border);">Flags</th>
|
||||
<th style="padding:10px 16px;text-align:left;font-family:'Barlow Condensed',sans-serif;font-size:0.65em;font-weight:700;letter-spacing:0.12em;text-transform:uppercase;color:var(--text2);border-bottom:1px solid var(--border);">Home</th>
|
||||
<th style="padding:10px 16px;text-align:left;font-family:'Barlow Condensed',sans-serif;font-size:0.65em;font-weight:700;letter-spacing:0.12em;text-transform:uppercase;color:var(--text2);border-bottom:1px solid var(--border);">Display</th>
|
||||
{#if editable}
|
||||
<th style="padding:10px 6px;border-bottom:1px solid var(--border);width:36px;"></th>
|
||||
<th style="padding:10px 6px;border-bottom:1px solid var(--border);width:36px;"></th>
|
||||
{/if}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each entries as entry (entry.key)}
|
||||
<tr
|
||||
data-entry-key={entry.key}
|
||||
style="
|
||||
border-bottom:1px solid var(--border);
|
||||
transition:background 0.1s;
|
||||
{draggingKey === entry.key ? 'opacity:0.35;' : ''}
|
||||
{dropTarget?.key === entry.key && dropTarget?.position === 'before' ? 'box-shadow:inset 0 2px 0 var(--amber);' : ''}
|
||||
{dropTarget?.key === entry.key && dropTarget?.position === 'after' ? 'box-shadow:inset 0 -2px 0 var(--amber);' : ''}
|
||||
"
|
||||
on:dragover|preventDefault={(event) => 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 = ''; }}
|
||||
>
|
||||
<!-- Channel -->
|
||||
<td style="padding:11px 16px;white-space:nowrap;font-family:'DM Mono',monospace;font-size:0.9em;font-weight:500;color:var(--text2);">
|
||||
{entry.isPair ? `${entry.leader.channel} / ${entry.follower.channel}` : entry.leader.channel}
|
||||
{#if entry.isPair}
|
||||
<span style="margin-left:6px;padding:2px 5px;border-radius:2px;background:var(--cyan-dim);border:1px solid rgba(45,212,200,0.2);color:var(--cyan);font-size:10px;font-family:'Barlow Condensed',sans-serif;font-weight:700;letter-spacing:0.1em;text-transform:uppercase;">16B</span>
|
||||
{/if}
|
||||
</td>
|
||||
|
||||
<!-- Attribute -->
|
||||
<td style="padding:11px 16px;">
|
||||
{#if editable}
|
||||
<select class="select" style="min-width:150px;font-family:'Barlow Condensed',sans-serif;font-weight:700;font-size:0.8em;"
|
||||
value={entry.leader.attributeId ?? 0}
|
||||
on:change={(e) => onUpdate(entry.key, { attributeId: Number(e.currentTarget.value) })}>
|
||||
{#each attributes as attribute, index}
|
||||
<option value={index}>{attribute}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{:else}
|
||||
<span style="font-family:'Barlow Condensed',sans-serif;font-weight:700;font-size:1.05em;letter-spacing:0.03em;color:{attrColor(entry.leader.attribute)};">
|
||||
{entry.leader.attribute || 'Not Used'}
|
||||
</span>
|
||||
{/if}
|
||||
</td>
|
||||
|
||||
<!-- Flags -->
|
||||
<td style="padding:11px 16px;">
|
||||
{#if editable}
|
||||
<div style="display:flex;flex-wrap:wrap;align-items:center;gap:8px 20px;">
|
||||
{#each ['Independent', 'LTP', 'Flipped'] as flag}
|
||||
<label style="display:flex;align-items:center;gap:6px;white-space:nowrap;cursor:pointer;">
|
||||
<input type="checkbox" style="accent-color:var(--amber);cursor:pointer;width:14px;height:14px;"
|
||||
checked={entry.leader.flags.includes(flag)}
|
||||
on:change={(e) => {
|
||||
const next = e.currentTarget.checked
|
||||
? [...new Set([...entry.leader.flags, flag])]
|
||||
: entry.leader.flags.filter((item) => item !== flag);
|
||||
onUpdate(entry.key, { flags: entry.isPair ? [...new Set([...next, '16-bit'])] : next.filter((item) => item !== '16-bit') });
|
||||
}}
|
||||
/>
|
||||
<span style="font-family:'Barlow Condensed',sans-serif;font-size:0.75em;font-weight:600;letter-spacing:0.06em;text-transform:uppercase;color:var(--text2);">{flag}</span>
|
||||
</label>
|
||||
{/each}
|
||||
<label style="display:flex;align-items:center;gap:6px;white-space:nowrap;cursor:pointer;">
|
||||
<input type="checkbox" style="accent-color:var(--cyan);cursor:pointer;width:14px;height:14px;"
|
||||
checked={entry.isPair}
|
||||
on:change={(e) => onTogglePair(entry.key, e.currentTarget.checked)}
|
||||
/>
|
||||
<span style="font-family:'Barlow Condensed',sans-serif;font-size:0.75em;font-weight:600;letter-spacing:0.06em;text-transform:uppercase;color:var(--cyan);">16-bit</span>
|
||||
</label>
|
||||
</div>
|
||||
{:else}
|
||||
<span style="font-family:'Barlow Condensed',sans-serif;font-size:0.95em;font-weight:600;color:var(--text2);">{entry.flagsLabel}</span>
|
||||
{/if}
|
||||
</td>
|
||||
|
||||
<!-- Home -->
|
||||
<td style="padding:11px 16px;">
|
||||
{#if editable}
|
||||
<input class="input" style="max-width:80px;" type="number" min="0" max="255" value={entry.home}
|
||||
on:change={(e) => onUpdate(entry.key, { home: Number(e.currentTarget.value) })} />
|
||||
{:else}
|
||||
<span style="font-family:'DM Mono',monospace;font-size:1em;font-weight:500;color:var(--text);">{entry.home}</span>
|
||||
{/if}
|
||||
</td>
|
||||
|
||||
<!-- Display -->
|
||||
<td style="padding:11px 16px;">
|
||||
{#if editable}
|
||||
<select class="select" style="min-width:110px;" value={entry.displayFormatId}
|
||||
on:change={(e) => onUpdate(entry.key, { displayFormatId: Number(e.currentTarget.value) })}>
|
||||
{#each DISPLAY_FORMATS as format, index}
|
||||
<option value={index}>{format}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{:else}
|
||||
<span style="font-family:'DM Mono',monospace;font-size:1em;font-weight:500;color:var(--text2);">{entry.displayFormat}</span>
|
||||
{/if}
|
||||
</td>
|
||||
|
||||
{#if editable}
|
||||
<td style="padding:11px 6px;">
|
||||
<button class="btn" style="padding:5px;cursor:grab;background:transparent;border-color:var(--border);" type="button" draggable="true"
|
||||
aria-label={`Drag to reorder channel ${entry.leader.channel}`}
|
||||
on:dragstart={() => onDragStart(entry.key)} on:dragend={onDragEnd}>
|
||||
<GripVertical size={15} />
|
||||
</button>
|
||||
</td>
|
||||
<td style="padding:11px 6px;">
|
||||
<button class="btn btn-danger" style="padding:5px;" type="button" on:click={() => confirmDelete(entry)}>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</td>
|
||||
{/if}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
108
src/lib/components/ClearDataModal.svelte
Normal file
108
src/lib/components/ClearDataModal.svelte
Normal file
@@ -0,0 +1,108 @@
|
||||
<script>
|
||||
export let onConfirm = (_keepTokens) => {};
|
||||
export let onCancel = () => {};
|
||||
|
||||
// Stats passed in so the modal can show what will be cleared
|
||||
export let fileCount = 0;
|
||||
export let snapshotCount = 0;
|
||||
|
||||
let keepTokens = true; // default: preserve owner tokens
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center backdrop-blur-sm"
|
||||
style="background:rgba(0,0,0,0.75);"
|
||||
role="presentation"
|
||||
on:click|self={onCancel}
|
||||
on:keydown={(e) => e.key === 'Escape' && onCancel()}
|
||||
>
|
||||
<div
|
||||
class="panel w-full"
|
||||
style="max-width:440px; padding:24px;
|
||||
box-shadow:0 0 0 1px rgba(248,113,113,0.2), 0 24px 64px rgba(0,0,0,0.8);"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="clear-modal-title"
|
||||
>
|
||||
<!-- Title -->
|
||||
<div style="display:flex; align-items:center; gap:8px; margin-bottom:16px;
|
||||
padding-bottom:12px; border-bottom:1px solid var(--border);">
|
||||
<div style="width:4px; height:20px; background:var(--red); border-radius:2px;
|
||||
box-shadow:0 0 8px rgba(248,113,113,0.4);"></div>
|
||||
<h2 id="clear-modal-title"
|
||||
style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:16px;
|
||||
letter-spacing:0.08em; text-transform:uppercase; color:var(--text);">
|
||||
Clear Local Data
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<!-- What will be cleared -->
|
||||
<p style="font-size:13px; color:var(--text2); line-height:1.7; margin-bottom:16px;">
|
||||
This will permanently remove all locally stored data from your browser.
|
||||
This cannot be undone.
|
||||
</p>
|
||||
|
||||
<div style="border-radius:3px; border:1px solid var(--border); background:var(--raised);
|
||||
padding:12px 14px; margin-bottom:16px;">
|
||||
<div style="font-family:'Barlow Condensed',sans-serif; font-size:11px; font-weight:700;
|
||||
letter-spacing:0.1em; text-transform:uppercase; color:var(--text3);
|
||||
margin-bottom:8px;">Will be cleared</div>
|
||||
<div style="display:flex; flex-direction:column; gap:5px;">
|
||||
{#each [
|
||||
{ label: 'File history', value: `${fileCount} file${fileCount !== 1 ? 's' : ''}` },
|
||||
{ label: 'Snapshots', value: `${snapshotCount} snapshot${snapshotCount !== 1 ? 's' : ''}` },
|
||||
{ label: 'Autosave', value: 'Current session' },
|
||||
{ label: 'View preference', value: 'Card / table setting' },
|
||||
{ label: 'Font scale', value: 'Size preference' },
|
||||
] as row}
|
||||
<div style="display:flex; justify-content:space-between; gap:12px;">
|
||||
<span style="font-size:13px; color:var(--text2);">{row.label}</span>
|
||||
<span style="font-family:'DM Mono',monospace; font-size:11px; color:var(--text3);">{row.value}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Owner token toggle -->
|
||||
<label
|
||||
style="display:flex; align-items:flex-start; gap:10px; padding:12px 14px;
|
||||
border-radius:3px; cursor:pointer;
|
||||
border:1px solid {keepTokens ? 'var(--border2)' : 'rgba(248,113,113,0.35)'};
|
||||
background:{keepTokens ? 'var(--raised)' : 'var(--red-dim)'};
|
||||
margin-bottom:20px; transition:all 0.15s;"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
style="margin-top:2px; accent-color:var(--red); cursor:pointer; flex-shrink:0;"
|
||||
bind:checked={keepTokens}
|
||||
/>
|
||||
<div>
|
||||
<div style="font-family:'Barlow Condensed',sans-serif; font-size:13px; font-weight:600;
|
||||
letter-spacing:0.04em; text-transform:uppercase;
|
||||
color:{keepTokens ? 'var(--text2)' : 'var(--red)'}; margin-bottom:3px;">
|
||||
{keepTokens ? 'Keep owner tokens' : 'Also clear owner tokens'}
|
||||
</div>
|
||||
<div style="font-size:12px; color:var(--text3); line-height:1.5;">
|
||||
{#if keepTokens}
|
||||
Your publish tokens will be preserved so you can still delete library entries.
|
||||
{:else}
|
||||
<strong style="color:var(--red);">Warning:</strong> you will lose the ability to delete
|
||||
any personalities you've published to the library.
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<!-- Actions -->
|
||||
<div style="display:flex; justify-content:flex-end; gap:8px;">
|
||||
<button class="btn" type="button" on:click={onCancel}>Cancel</button>
|
||||
<button
|
||||
class="btn btn-danger"
|
||||
type="button"
|
||||
on:click={() => onConfirm(keepTokens)}
|
||||
>
|
||||
Clear Data
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
65
src/lib/components/DropdownMenu.svelte
Normal file
65
src/lib/components/DropdownMenu.svelte
Normal file
@@ -0,0 +1,65 @@
|
||||
<script>
|
||||
import { clickOutside } from '$lib/utils/clickOutside';
|
||||
|
||||
export let icon;
|
||||
export let label = '';
|
||||
export let items = [];
|
||||
export let emptyText = 'Nothing here yet.';
|
||||
export let onSelect = () => {};
|
||||
export let align = 'left';
|
||||
|
||||
let open = false;
|
||||
let query = '';
|
||||
|
||||
$: normalizedQuery = query.trim().toLowerCase();
|
||||
$: filteredItems = normalizedQuery
|
||||
? items.filter((item) => `${item.title ?? ''} ${item.subtitle ?? ''}`.toLowerCase().includes(normalizedQuery))
|
||||
: items;
|
||||
</script>
|
||||
|
||||
<div class="relative" use:clickOutside={() => (open = false)}>
|
||||
<button
|
||||
class="btn"
|
||||
style={open ? 'border-color:var(--amber); color:var(--amber); background:var(--amber-dim);' : ''}
|
||||
type="button"
|
||||
on:click={() => (open = !open)}
|
||||
aria-expanded={open}
|
||||
>
|
||||
{#if icon}
|
||||
<svelte:component this={icon} size={13} />
|
||||
{/if}
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
|
||||
{#if open}
|
||||
<div class={`menu-panel ${align === 'right' ? 'right-0 left-auto' : ''}`} style={align === 'right' ? 'left:auto; right:0;' : ''}>
|
||||
{#if items.length > 8}
|
||||
<div class="mb-2">
|
||||
<input class="input" type="search" bind:value={query} placeholder={`Search ${label.toLowerCase()}...`} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if filteredItems.length}
|
||||
<div class="flex max-h-80 flex-col gap-1 overflow-auto pr-1">
|
||||
{#each filteredItems as item}
|
||||
<button
|
||||
class="w-full px-3 py-2 text-left transition"
|
||||
style="border-radius:3px; border:1px solid var(--border); background:var(--raised);"
|
||||
type="button"
|
||||
on:mouseenter={(e) => { e.currentTarget.style.borderColor='var(--border2)'; e.currentTarget.style.background='#1f271f'; }}
|
||||
on:mouseleave={(e) => { e.currentTarget.style.borderColor='var(--border)'; e.currentTarget.style.background='var(--raised)'; }}
|
||||
on:click={() => { open = false; query = ''; onSelect(item); }}
|
||||
>
|
||||
<div style="font-family:'Barlow Condensed',sans-serif; font-weight:600; font-size:13px; letter-spacing:0.03em; color:var(--text);">{item.title}</div>
|
||||
{#if item.subtitle}
|
||||
<div style="font-family:'DM Mono',monospace; font-size:10px; color:var(--text3); margin-top:2px;">{item.subtitle}</div>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="subtle px-1 py-2">{normalizedQuery ? 'No matching items.' : emptyText}</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
57
src/lib/components/Footer.svelte
Normal file
57
src/lib/components/Footer.svelte
Normal file
@@ -0,0 +1,57 @@
|
||||
<footer style="
|
||||
margin-top: auto;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
padding: 24px 20px;
|
||||
">
|
||||
<div style="max-width:1400px; margin:0 auto;
|
||||
display:flex; flex-wrap:wrap; align-items:center;
|
||||
justify-content:space-between; gap:16px;">
|
||||
|
||||
<!-- Left: branding + tagline -->
|
||||
<div style="display:flex; align-items:center; gap:12px;">
|
||||
<div style="width:4px; height:22px; background:var(--amber);
|
||||
border-radius:2px; box-shadow:0 0 6px rgba(232,147,10,0.4);"></div>
|
||||
<div>
|
||||
<div style="font-family:'Barlow Condensed',sans-serif; font-weight:700;
|
||||
font-size:16px; letter-spacing:0.1em; text-transform:uppercase;
|
||||
color:var(--text);">ETC PRS Editor</div>
|
||||
<div style="font-family:'DM Mono',monospace; font-size:12px;
|
||||
color:var(--cyan); margin-top:2px; opacity:0.85;">
|
||||
made with <span style="color:var(--amber);">♥</span> for the lighting community
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Center: nav links -->
|
||||
<nav style="display:flex; align-items:center; gap:4px; flex-wrap:wrap;">
|
||||
{#each [
|
||||
{ href: '/', label: 'App' },
|
||||
{ href: '/library', label: 'Library' },
|
||||
{ href: '/about', label: 'About' },
|
||||
{ href: '/contact', label: 'Contact' },
|
||||
{ href: '/disclosures', label: 'Disclosures' },
|
||||
] as link}
|
||||
<a
|
||||
href={link.href}
|
||||
style="font-family:'Barlow Condensed',sans-serif; font-size:14px; font-weight:600;
|
||||
letter-spacing:0.06em; text-transform:uppercase; color:var(--text2);
|
||||
text-decoration:none; padding:5px 10px; border-radius:3px;
|
||||
transition:color 0.15s;"
|
||||
on:mouseenter={(e) => e.currentTarget.style.color = 'var(--amber)'}
|
||||
on:mouseleave={(e) => e.currentTarget.style.color = 'var(--text2)'}
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<!-- Right: disclaimer blurb -->
|
||||
<div style="font-family:'DM Mono',monospace; font-size:12px; color:var(--text2);
|
||||
text-align:right; line-height:1.7; max-width:300px;">
|
||||
Not affiliated with or endorsed by<br/>
|
||||
ETC (Electronic Theatre Controls, Inc.)
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</footer>
|
||||
262
src/lib/components/GlobalMenu.svelte
Normal file
262
src/lib/components/GlobalMenu.svelte
Normal file
@@ -0,0 +1,262 @@
|
||||
<script>
|
||||
import { FileUp, FilePlus2, FolderClock, History, Home, ChevronDown } from 'lucide-svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import { onMount } from 'svelte';
|
||||
import { clickOutside } from '$lib/utils/clickOutside.js';
|
||||
|
||||
export let previousFiles = [];
|
||||
export let snapshots = [];
|
||||
export let onOpenFile = () => {};
|
||||
export let onNewFile = () => {};
|
||||
export let onOpenStoredFile = () => {};
|
||||
export let onOpenSnapshot = () => {};
|
||||
export let onGoHome = () => {};
|
||||
export let currentMode = 'root';
|
||||
export let currentFileId = null;
|
||||
|
||||
let input;
|
||||
let fileMenuOpen = false;
|
||||
let openReportCount = null;
|
||||
|
||||
onMount(async () => {
|
||||
if (!browser) return;
|
||||
try {
|
||||
const res = await fetch('/api/admin/report-count');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
openReportCount = data.count;
|
||||
}
|
||||
} catch { /* non-fatal */ }
|
||||
});
|
||||
|
||||
$: recentFileItems = previousFiles.slice(0, 5).map((file) => ({
|
||||
...file,
|
||||
title: file.name,
|
||||
subtitle: [file.fileName, `${file.channelCount} ch`, new Date(file.lastOpenedAt).toLocaleString()]
|
||||
.filter(Boolean).join(' · ')
|
||||
}));
|
||||
|
||||
$: snapshotItems = snapshots.map((snapshot) => ({
|
||||
...snapshot,
|
||||
title: snapshot.label,
|
||||
subtitle: new Date(snapshot.createdAt).toLocaleString()
|
||||
}));
|
||||
|
||||
$: subtitleText = currentMode === 'root'
|
||||
? 'Ready to open or create a personality file.'
|
||||
: currentMode === 'editor'
|
||||
? 'Editing — unsaved changes are autosaved.'
|
||||
: 'Viewing — open the editor to make changes.';
|
||||
|
||||
function closeFileMenu() { fileMenuOpen = false; }
|
||||
</script>
|
||||
|
||||
<div class="sticky top-0 z-20 mb-4 overflow-visible"
|
||||
style="background:var(--surface); border-bottom:1px solid var(--border); backdrop-filter:blur(12px);">
|
||||
<div class="mx-auto flex items-center justify-between gap-3 px-4"
|
||||
style="max-width:1400px; height:48px;">
|
||||
|
||||
<!-- Brand -->
|
||||
<div class="flex items-center gap-3 flex-shrink-0"
|
||||
style="border-right:1px solid var(--border); padding-right:16px; margin-right:4px;">
|
||||
<div style="width:6px; height:26px; background:var(--amber);
|
||||
box-shadow:0 0 10px rgba(232,147,10,0.6); border-radius:2px;"></div>
|
||||
<div>
|
||||
<div style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:14px;
|
||||
letter-spacing:0.12em; text-transform:uppercase; color:var(--text);">ETC PRS</div>
|
||||
<div style="font-family:'DM Mono',monospace; font-size:9px; letter-spacing:0.1em;
|
||||
color:var(--cyan); opacity:0.8; text-transform:uppercase; margin-top:1px;">
|
||||
Personality Editor
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<div style="font-family:'DM Mono',monospace; font-size:11px; color:var(--amber);
|
||||
opacity:0.7; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">
|
||||
{subtitleText}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
|
||||
<!-- Home — only shown when not on the home screen -->
|
||||
{#if currentMode !== 'root'}
|
||||
<button class="btn" type="button" on:click={onGoHome} title="Home">
|
||||
<Home size={13} />
|
||||
<span>Home</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Library link -->
|
||||
<a class="btn" href="/library" style="text-decoration:none;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="2.5"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/>
|
||||
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/>
|
||||
</svg>
|
||||
Library
|
||||
</a>
|
||||
|
||||
<!-- File menu dropdown -->
|
||||
<div class="relative" use:clickOutside={closeFileMenu}>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
type="button"
|
||||
on:click={() => fileMenuOpen = !fileMenuOpen}
|
||||
aria-expanded={fileMenuOpen}
|
||||
>
|
||||
<FileUp size={13} />
|
||||
<span>File</span>
|
||||
<ChevronDown size={11} style="opacity:0.7; margin-left:-2px; transition:transform 0.15s;
|
||||
{fileMenuOpen ? 'transform:rotate(180deg)' : ''}" />
|
||||
</button>
|
||||
|
||||
{#if fileMenuOpen}
|
||||
<div class="menu-panel" style="right:0; left:auto; min-width:260px;">
|
||||
|
||||
<!-- Open -->
|
||||
<button
|
||||
class="w-full text-left"
|
||||
style="display:flex; align-items:center; gap:10px; padding:9px 12px;
|
||||
border-radius:3px; transition:background 0.1s; color:var(--text);
|
||||
background:transparent; border:none; cursor:pointer; width:100%;"
|
||||
type="button"
|
||||
on:mouseenter={(e) => e.currentTarget.style.background='var(--raised)'}
|
||||
on:mouseleave={(e) => e.currentTarget.style.background='transparent'}
|
||||
on:click={() => { closeFileMenu(); input?.click(); }}
|
||||
>
|
||||
<FileUp size={14} style="color:var(--amber); flex-shrink:0;" />
|
||||
<div>
|
||||
<div style="font-family:'Barlow Condensed',sans-serif; font-weight:600;
|
||||
font-size:13px; letter-spacing:0.04em;">Open PRS file</div>
|
||||
<div style="font-family:'DM Mono',monospace; font-size:10px;
|
||||
color:var(--text3); margin-top:1px;">Load a .prs file from your computer</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- New -->
|
||||
<button
|
||||
class="w-full text-left"
|
||||
style="display:flex; align-items:center; gap:10px; padding:9px 12px;
|
||||
border-radius:3px; transition:background 0.1s; color:var(--text);
|
||||
background:transparent; border:none; cursor:pointer; width:100%;"
|
||||
type="button"
|
||||
on:mouseenter={(e) => e.currentTarget.style.background='var(--raised)'}
|
||||
on:mouseleave={(e) => e.currentTarget.style.background='transparent'}
|
||||
on:click={() => { closeFileMenu(); onNewFile(); }}
|
||||
>
|
||||
<FilePlus2 size={14} style="color:var(--amber); flex-shrink:0;" />
|
||||
<div>
|
||||
<div style="font-family:'Barlow Condensed',sans-serif; font-weight:600;
|
||||
font-size:13px; letter-spacing:0.04em;">New personality</div>
|
||||
<div style="font-family:'DM Mono',monospace; font-size:10px;
|
||||
color:var(--text3); margin-top:1px;">Start a blank fixture file</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- Divider -->
|
||||
<div style="height:1px; background:var(--border); margin:6px 0;"></div>
|
||||
|
||||
<!-- Recent files -->
|
||||
<div style="padding:4px 12px 4px; font-family:'Barlow Condensed',sans-serif;
|
||||
font-size:10px; font-weight:700; letter-spacing:0.12em;
|
||||
text-transform:uppercase; color:var(--text3);">
|
||||
<FolderClock size={11} style="display:inline; margin-right:5px; vertical-align:-1px;" />
|
||||
Recent
|
||||
</div>
|
||||
{#if recentFileItems.length}
|
||||
{#each recentFileItems as item}
|
||||
<button
|
||||
class="w-full text-left"
|
||||
style="display:flex; align-items:center; gap:10px; padding:7px 12px;
|
||||
border-radius:3px; transition:background 0.1s; color:var(--text);
|
||||
background:transparent; border:none; cursor:pointer; width:100%;"
|
||||
type="button"
|
||||
on:mouseenter={(e) => e.currentTarget.style.background='var(--raised)'}
|
||||
on:mouseleave={(e) => e.currentTarget.style.background='transparent'}
|
||||
on:click={() => { closeFileMenu(); onOpenStoredFile(item); }}
|
||||
>
|
||||
<div style="min-width:0;">
|
||||
<div style="font-family:'Barlow Condensed',sans-serif; font-weight:600;
|
||||
font-size:13px; letter-spacing:0.03em; white-space:nowrap;
|
||||
overflow:hidden; text-overflow:ellipsis;">{item.title}</div>
|
||||
<div style="font-family:'DM Mono',monospace; font-size:10px;
|
||||
color:var(--text3); margin-top:1px; white-space:nowrap;
|
||||
overflow:hidden; text-overflow:ellipsis;">{item.subtitle}</div>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
{:else}
|
||||
<div style="padding:6px 12px; font-family:'DM Mono',monospace; font-size:11px;
|
||||
color:var(--text3);">No recent files.</div>
|
||||
{/if}
|
||||
|
||||
<!-- Snapshots — only when a file is open -->
|
||||
{#if currentFileId && snapshotItems.length > 0}
|
||||
<div style="height:1px; background:var(--border); margin:6px 0;"></div>
|
||||
<div style="padding:4px 12px 4px; font-family:'Barlow Condensed',sans-serif;
|
||||
font-size:10px; font-weight:700; letter-spacing:0.12em;
|
||||
text-transform:uppercase; color:var(--text3);">
|
||||
<History size={11} style="display:inline; margin-right:5px; vertical-align:-1px;" />
|
||||
Snapshots
|
||||
</div>
|
||||
{#each snapshotItems as item}
|
||||
<button
|
||||
class="w-full text-left"
|
||||
style="display:flex; align-items:center; gap:10px; padding:7px 12px;
|
||||
border-radius:3px; transition:background 0.1s; color:var(--text);
|
||||
background:transparent; border:none; cursor:pointer; width:100%;"
|
||||
type="button"
|
||||
on:mouseenter={(e) => e.currentTarget.style.background='var(--raised)'}
|
||||
on:mouseleave={(e) => e.currentTarget.style.background='transparent'}
|
||||
on:click={() => { closeFileMenu(); onOpenSnapshot(item); }}
|
||||
>
|
||||
<div style="min-width:0;">
|
||||
<div style="font-family:'Barlow Condensed',sans-serif; font-weight:600;
|
||||
font-size:13px; letter-spacing:0.03em; white-space:nowrap;
|
||||
overflow:hidden; text-overflow:ellipsis;">{item.title}</div>
|
||||
<div style="font-family:'DM Mono',monospace; font-size:10px;
|
||||
color:var(--text3); margin-top:1px;">{item.subtitle}</div>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Hidden file input -->
|
||||
<input bind:this={input} class="hidden" type="file"
|
||||
accept=".prs,application/octet-stream"
|
||||
on:change={(event) => onOpenFile(event)} />
|
||||
|
||||
<!-- Admin badge -->
|
||||
{#if openReportCount !== null}
|
||||
<a href="/admin"
|
||||
style="position:relative; display:inline-flex; align-items:center; justify-content:center;
|
||||
font-family:'DM Mono',monospace; font-size:14px; color:var(--text3);
|
||||
text-decoration:none; padding:4px 6px; border-radius:3px; transition:color 0.15s;"
|
||||
on:mouseenter={(e) => e.currentTarget.style.color='var(--red)'}
|
||||
on:mouseleave={(e) => e.currentTarget.style.color='var(--text3)'}
|
||||
title="Admin panel">
|
||||
⚙
|
||||
{#if openReportCount > 0}
|
||||
<span style="position:absolute; top:-3px; right:-3px; min-width:16px; height:16px;
|
||||
padding:0 4px; border-radius:99px; background:var(--red); color:#fff;
|
||||
font-family:'DM Mono',monospace; font-size:9px; font-weight:700;
|
||||
display:flex; align-items:center; justify-content:center; line-height:1;
|
||||
box-shadow:0 0 6px rgba(248,113,113,0.6);">
|
||||
{openReportCount > 99 ? '99+' : openReportCount}
|
||||
</span>
|
||||
{/if}
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
155
src/lib/components/PublishModal.svelte
Normal file
155
src/lib/components/PublishModal.svelte
Normal file
@@ -0,0 +1,155 @@
|
||||
<script>
|
||||
import TagInput from './TagInput.svelte';
|
||||
|
||||
export let personality = null;
|
||||
export let displayName = '';
|
||||
export let onConfirm = async () => {};
|
||||
export let onCancel = () => {};
|
||||
|
||||
let libraryName = displayName || personality?.name || '';
|
||||
let manufacturer = '';
|
||||
let tags = [];
|
||||
let creatorHandle = '';
|
||||
let submitting = false;
|
||||
let errorMsg = '';
|
||||
|
||||
let manufacturerSuggestions = [];
|
||||
|
||||
async function loadManufacturers() {
|
||||
try {
|
||||
const res = await fetch('/api/manufacturers');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
manufacturerSuggestions = data.manufacturers ?? [];
|
||||
}
|
||||
} catch { /* non-fatal */ }
|
||||
}
|
||||
|
||||
loadManufacturers();
|
||||
|
||||
async function handleSubmit() {
|
||||
if (submitting) return;
|
||||
if (!libraryName.trim()) { errorMsg = 'Library name is required.'; return; }
|
||||
submitting = true;
|
||||
errorMsg = '';
|
||||
try {
|
||||
await onConfirm({ manufacturer, tags, creator_handle: creatorHandle, library_name: libraryName.trim() });
|
||||
} catch (err) {
|
||||
errorMsg = err?.message ?? 'Something went wrong. Please try again.';
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center backdrop-blur-sm"
|
||||
style="background:rgba(0,0,0,0.75);"
|
||||
role="presentation"
|
||||
on:click|self={onCancel}
|
||||
on:keydown={(e) => e.key === 'Escape' && onCancel()}
|
||||
>
|
||||
<div
|
||||
class="panel w-full"
|
||||
style="max-width:480px; padding:24px;
|
||||
box-shadow:0 0 0 1px rgba(232,147,10,0.15), 0 24px 64px rgba(0,0,0,0.8);"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="publish-modal-title"
|
||||
>
|
||||
<!-- Title -->
|
||||
<div style="display:flex; align-items:center; gap:8px; margin-bottom:16px; padding-bottom:12px; border-bottom:1px solid var(--border);">
|
||||
<div style="width:4px; height:20px; background:var(--amber); border-radius:2px; box-shadow:0 0 8px rgba(232,147,10,0.5);"></div>
|
||||
<h2 id="publish-modal-title" style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:16px; letter-spacing:0.08em; text-transform:uppercase; color:var(--text);">
|
||||
Publish to Library
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<!-- Fixture summary (read-only context) -->
|
||||
<div style="padding:10px 12px; border-radius:3px; background:var(--raised); border:1px solid var(--border); margin-bottom:16px;
|
||||
display:flex; align-items:center; justify-content:space-between; gap:12px;">
|
||||
<div>
|
||||
<div style="font-family:'DM Mono',monospace; font-size:10px; color:var(--text3); text-transform:uppercase; letter-spacing:0.1em; margin-bottom:3px;">PRS binary name</div>
|
||||
<div style="font-family:'DM Mono',monospace; font-weight:600; font-size:15px; color:var(--amber);">{personality?.name || '(untitled)'}</div>
|
||||
</div>
|
||||
<div style="text-align:right;">
|
||||
<div style="font-family:'DM Mono',monospace; font-size:10px; color:var(--text3); text-transform:uppercase; letter-spacing:0.1em; margin-bottom:3px;">Channels</div>
|
||||
<div style="font-family:'DM Mono',monospace; font-size:15px; color:var(--text2);">{personality?.channelCount ?? 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Library name (editable) -->
|
||||
<div style="margin-bottom:14px;">
|
||||
<label class="label" for="pub-library-name" style="display:block; margin-bottom:6px;">
|
||||
Library Name
|
||||
<span style="font-weight:400; text-transform:none; letter-spacing:0; color:var(--text3); font-family:'DM Mono',monospace; font-size:10px; margin-left:6px;">shown in the library listing</span>
|
||||
</label>
|
||||
<input
|
||||
id="pub-library-name"
|
||||
class="input"
|
||||
type="text"
|
||||
bind:value={libraryName}
|
||||
maxlength="120"
|
||||
placeholder="Full fixture display name…"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Manufacturer -->
|
||||
<div style="margin-bottom:14px;">
|
||||
<label class="label" for="pub-manufacturer" style="display:block; margin-bottom:6px;">Manufacturer</label>
|
||||
<input
|
||||
id="pub-manufacturer"
|
||||
class="input"
|
||||
type="text"
|
||||
list="manufacturer-list"
|
||||
bind:value={manufacturer}
|
||||
placeholder="e.g. Chauvet Professional"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<datalist id="manufacturer-list">
|
||||
{#each manufacturerSuggestions as m}
|
||||
<option value={m}>{m}</option>
|
||||
{/each}
|
||||
</datalist>
|
||||
</div>
|
||||
|
||||
<!-- Tags -->
|
||||
<div style="margin-bottom:14px;">
|
||||
<label class="label" style="display:block; margin-bottom:6px;">Tags</label>
|
||||
<TagInput bind:tags onChange={(t) => tags = t} />
|
||||
</div>
|
||||
|
||||
<!-- Handle -->
|
||||
<div style="margin-bottom:20px;">
|
||||
<label class="label" for="pub-handle" style="display:block; margin-bottom:6px;">
|
||||
Your name / handle
|
||||
<span style="font-weight:400; text-transform:none; letter-spacing:0; color:var(--text3); font-family:'DM Mono',monospace; font-size:10px; margin-left:6px;">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
id="pub-handle"
|
||||
class="input"
|
||||
type="text"
|
||||
bind:value={creatorHandle}
|
||||
placeholder="e.g. Raine"
|
||||
maxlength="64"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if errorMsg}
|
||||
<div style="margin-bottom:14px; padding:10px 12px; border-radius:3px; border:1px solid rgba(248,113,113,0.3); background:var(--red-dim); color:var(--red); font-size:13px;">
|
||||
{errorMsg}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Actions -->
|
||||
<div style="display:flex; justify-content:flex-end; gap:8px;">
|
||||
<button class="btn" type="button" on:click={onCancel} disabled={submitting}>Cancel</button>
|
||||
<button class="btn btn-primary" type="button" on:click={handleSubmit} disabled={submitting}>
|
||||
{#if submitting}
|
||||
Publishing…
|
||||
{:else}
|
||||
Publish
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
115
src/lib/components/PublishSuccessModal.svelte
Normal file
115
src/lib/components/PublishSuccessModal.svelte
Normal file
@@ -0,0 +1,115 @@
|
||||
<script>
|
||||
import { CheckCircle, Copy, Download, ExternalLink } from 'lucide-svelte';
|
||||
|
||||
export let result = null; // { id, slug, url, owner_token }
|
||||
export let onDone = () => {};
|
||||
|
||||
let urlCopied = false;
|
||||
let tokenCopied = false;
|
||||
|
||||
function copyText(text, which) {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
if (which === 'url') { urlCopied = true; setTimeout(() => urlCopied = false, 2000); }
|
||||
if (which === 'token') { tokenCopied = true; setTimeout(() => tokenCopied = false, 2000); }
|
||||
});
|
||||
}
|
||||
|
||||
function downloadToken() {
|
||||
const content = [
|
||||
`ETC PRS Library — Owner Token`,
|
||||
`================================`,
|
||||
`Personality: ${result?.url ?? ''}`,
|
||||
`Token: ${result?.owner_token ?? ''}`,
|
||||
``,
|
||||
`Keep this token safe. It is the only way to delete this upload.`,
|
||||
`It will not be shown again.`,
|
||||
].join('\n');
|
||||
|
||||
const blob = new Blob([content], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `prs-token-${result?.id ?? 'unknown'}.txt`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center backdrop-blur-sm"
|
||||
style="background:rgba(0,0,0,0.75);"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="success-modal-title"
|
||||
>
|
||||
<div
|
||||
class="panel w-full"
|
||||
style="max-width:520px; padding:24px;
|
||||
box-shadow:0 0 0 1px rgba(45,212,200,0.2), 0 24px 64px rgba(0,0,0,0.8);"
|
||||
>
|
||||
<!-- Title -->
|
||||
<div style="display:flex; align-items:center; gap:10px; margin-bottom:20px; padding-bottom:14px; border-bottom:1px solid var(--border);">
|
||||
<CheckCircle size={20} style="color:var(--cyan); flex-shrink:0;" />
|
||||
<h2 id="success-modal-title" style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:16px; letter-spacing:0.08em; text-transform:uppercase; color:var(--text);">
|
||||
Published Successfully
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<!-- Share URL -->
|
||||
<div style="margin-bottom:16px;">
|
||||
<div class="label" style="margin-bottom:6px;">Share Link</div>
|
||||
<div style="display:flex; gap:6px;">
|
||||
<div style="flex:1; font-family:'DM Mono',monospace; font-size:12px; color:var(--cyan);
|
||||
padding:8px 10px; border-radius:3px; border:1px solid var(--border2);
|
||||
background:var(--bg); overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">
|
||||
{result?.url ?? ''}
|
||||
</div>
|
||||
<button class="btn" style="flex-shrink:0; padding:6px 10px;" type="button"
|
||||
on:click={() => copyText(result?.url, 'url')}>
|
||||
<Copy size={13} />
|
||||
{urlCopied ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
<a class="btn" href={result?.url} target="_blank" rel="noopener noreferrer"
|
||||
style="flex-shrink:0; padding:6px 10px; text-decoration:none;">
|
||||
<ExternalLink size={13} />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Owner Token — prominent warning -->
|
||||
<div style="padding:14px; border-radius:3px; border:1px solid rgba(232,147,10,0.4);
|
||||
background:rgba(232,147,10,0.06); margin-bottom:16px;">
|
||||
<div style="display:flex; align-items:center; gap:6px; margin-bottom:8px;">
|
||||
<div style="width:3px; height:16px; background:var(--amber); border-radius:2px;"></div>
|
||||
<div class="label" style="color:var(--amber);">Owner Token — Save This Now</div>
|
||||
</div>
|
||||
<div style="font-family:'DM Mono',monospace; font-size:11px; color:var(--text3);
|
||||
margin-bottom:10px; line-height:1.6;">
|
||||
This token lets you delete your upload. It will <strong style="color:var(--text2);">never be shown again</strong>.
|
||||
Save it somewhere safe — copy it, download it, or store it in a password manager.
|
||||
</div>
|
||||
<div style="font-family:'DM Mono',monospace; font-size:12px; color:var(--amber);
|
||||
padding:8px 10px; border-radius:3px; border:1px solid rgba(232,147,10,0.2);
|
||||
background:var(--bg); word-break:break-all; margin-bottom:10px;">
|
||||
{result?.owner_token ?? ''}
|
||||
</div>
|
||||
<div style="display:flex; gap:6px;">
|
||||
<button class="btn" type="button" on:click={() => copyText(result?.owner_token, 'token')}
|
||||
style="flex:1; justify-content:center;">
|
||||
<Copy size={13} />
|
||||
{tokenCopied ? 'Copied!' : 'Copy Token'}
|
||||
</button>
|
||||
<button class="btn" type="button" on:click={downloadToken}
|
||||
style="flex:1; justify-content:center;">
|
||||
<Download size={13} />
|
||||
Download as .txt
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary" type="button" on:click={onDone}
|
||||
style="width:100%; justify-content:center;">
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
144
src/lib/components/ReportModal.svelte
Normal file
144
src/lib/components/ReportModal.svelte
Normal file
@@ -0,0 +1,144 @@
|
||||
<script>
|
||||
export let personalityId = '';
|
||||
export let personalityName = '';
|
||||
export let onDone = () => {};
|
||||
export let onCancel = () => {};
|
||||
|
||||
const REASONS = [
|
||||
{ value: 'incorrect-data', label: 'Incorrect fixture data' },
|
||||
{ value: 'duplicate', label: 'Duplicate upload' },
|
||||
{ value: 'inappropriate', label: 'Inappropriate content' },
|
||||
{ value: 'spam', label: 'Spam / test upload' },
|
||||
{ value: 'other', label: 'Other' },
|
||||
];
|
||||
|
||||
let reason = '';
|
||||
let notes = '';
|
||||
let submitting = false;
|
||||
let submitted = false;
|
||||
let errorMsg = '';
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!reason) { errorMsg = 'Please select a reason.'; return; }
|
||||
submitting = true;
|
||||
errorMsg = '';
|
||||
try {
|
||||
const res = await fetch('/api/report', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ personality_id: personalityId, reason, notes })
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.message ?? `Error ${res.status}`);
|
||||
}
|
||||
submitted = true;
|
||||
} catch (err) {
|
||||
errorMsg = err.message;
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center backdrop-blur-sm"
|
||||
style="background:rgba(0,0,0,0.75);"
|
||||
role="presentation"
|
||||
on:click|self={onCancel}
|
||||
on:keydown={(e) => e.key === 'Escape' && onCancel()}
|
||||
>
|
||||
<div
|
||||
class="panel w-full"
|
||||
style="max-width:420px; padding:24px;
|
||||
box-shadow:0 0 0 1px rgba(248,113,113,0.15), 0 24px 64px rgba(0,0,0,0.8);"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="report-modal-title"
|
||||
>
|
||||
{#if submitted}
|
||||
<!-- Success state -->
|
||||
<div style="text-align:center; padding:12px 0;">
|
||||
<div style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:18px;
|
||||
letter-spacing:0.06em; text-transform:uppercase; color:var(--green); margin-bottom:10px;">
|
||||
Report Submitted
|
||||
</div>
|
||||
<p style="font-size:13px; color:var(--text2); line-height:1.6; margin-bottom:20px;">
|
||||
Thank you. Our team will review this personality shortly.
|
||||
</p>
|
||||
<button class="btn btn-primary" type="button" on:click={onDone}
|
||||
style="width:100%; justify-content:center;">Done</button>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Title -->
|
||||
<div style="display:flex; align-items:center; gap:8px; margin-bottom:16px;
|
||||
padding-bottom:12px; border-bottom:1px solid var(--border);">
|
||||
<div style="width:4px; height:20px; background:var(--red); border-radius:2px;
|
||||
box-shadow:0 0 8px rgba(248,113,113,0.4);"></div>
|
||||
<div>
|
||||
<h2 id="report-modal-title" style="font-family:'Barlow Condensed',sans-serif; font-weight:700;
|
||||
font-size:15px; letter-spacing:0.08em; text-transform:uppercase; color:var(--text);">
|
||||
Report Personality
|
||||
</h2>
|
||||
<div style="font-family:'DM Mono',monospace; font-size:10px; color:var(--text3); margin-top:1px;">
|
||||
{personalityName}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reason -->
|
||||
<div style="margin-bottom:14px;">
|
||||
<label class="label" style="display:block; margin-bottom:8px;">Reason</label>
|
||||
<div style="display:flex; flex-direction:column; gap:6px;">
|
||||
{#each REASONS as r}
|
||||
<label style="display:flex; align-items:center; gap:9px; padding:8px 10px;
|
||||
border-radius:3px; border:1px solid {reason === r.value ? 'var(--amber)' : 'var(--border2)'};
|
||||
background:{reason === r.value ? 'var(--amber-dim)' : 'var(--bg)'};
|
||||
cursor:pointer; transition:border-color 0.15s;">
|
||||
<input type="radio" name="reason" value={r.value} bind:group={reason}
|
||||
style="accent-color:var(--amber); cursor:pointer;" />
|
||||
<span style="font-family:'Barlow Condensed',sans-serif; font-size:13px; font-weight:600;
|
||||
letter-spacing:0.04em; color:{reason === r.value ? 'var(--amber)' : 'var(--text2)'};">
|
||||
{r.label}
|
||||
</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notes -->
|
||||
<div style="margin-bottom:18px;">
|
||||
<label class="label" for="report-notes" style="display:block; margin-bottom:6px;">
|
||||
Additional notes
|
||||
<span style="font-weight:400; text-transform:none; letter-spacing:0;
|
||||
color:var(--text3); font-family:'DM Mono',monospace; font-size:10px; margin-left:6px;">
|
||||
(optional)
|
||||
</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="report-notes"
|
||||
class="input resize-none"
|
||||
rows="3"
|
||||
maxlength="500"
|
||||
placeholder="Any additional context…"
|
||||
bind:value={notes}
|
||||
style="font-size:13px;"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
{#if errorMsg}
|
||||
<div style="margin-bottom:14px; padding:9px 12px; border-radius:3px;
|
||||
border:1px solid rgba(248,113,113,0.3); background:var(--red-dim);
|
||||
color:var(--red); font-size:12px;">
|
||||
{errorMsg}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div style="display:flex; justify-content:flex-end; gap:8px;">
|
||||
<button class="btn" type="button" on:click={onCancel} disabled={submitting}>Cancel</button>
|
||||
<button class="btn btn-danger" type="button" on:click={handleSubmit} disabled={submitting || !reason}>
|
||||
{submitting ? 'Submitting…' : 'Submit Report'}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
133
src/lib/components/SecondaryBar.svelte
Normal file
133
src/lib/components/SecondaryBar.svelte
Normal file
@@ -0,0 +1,133 @@
|
||||
<script>
|
||||
import { Pencil, Plus, Save, Download, Table2, LayoutGrid, ArrowLeft, ZoomIn, ZoomOut, RotateCcw, Globe, Trash2 } from 'lucide-svelte';
|
||||
|
||||
export let mode = 'viewer';
|
||||
export let view = 'cards';
|
||||
export let uiScale = 1.0;
|
||||
export let SCALE_MIN = 0.6;
|
||||
export let SCALE_MAX = 1.4;
|
||||
export let onViewChange = () => {};
|
||||
export let onOpenEditor = () => {};
|
||||
export let onExitEditor = () => {};
|
||||
export let onAddChannel = () => {};
|
||||
export let onSaveSnapshot = () => {};
|
||||
export let onDownload = () => {};
|
||||
export let onPublish = () => {};
|
||||
export let onDelete = () => {};
|
||||
export let onScaleUp = () => {};
|
||||
export let onScaleDown = () => {};
|
||||
export let onScaleReset = () => {};
|
||||
|
||||
$: scalePercent = Math.round(uiScale * 100);
|
||||
$: atMin = uiScale <= SCALE_MIN;
|
||||
$: atMax = uiScale >= SCALE_MAX;
|
||||
</script>
|
||||
|
||||
<div class="sticky z-10 mb-4" style="top:48px; background:var(--raised); border-bottom:1px solid var(--border); padding:8px 20px;">
|
||||
<div style="max-width:1400px; margin:0 auto; display:flex; flex-wrap:wrap; align-items:center; justify-content:space-between; gap:8px;">
|
||||
|
||||
<!-- Left: mode actions -->
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
{#if mode === 'viewer'}
|
||||
<button class="btn" type="button" on:click={onOpenEditor}>
|
||||
<Pencil size={13} /><span>Edit</span>
|
||||
</button>
|
||||
<button class="btn" type="button" on:click={onPublish}>
|
||||
<Globe size={13} /><span>Publish</span>
|
||||
</button>
|
||||
<button class="btn btn-primary" type="button" on:click={onDownload}>
|
||||
<Download size={13} /><span>Export PRS</span>
|
||||
</button>
|
||||
<div style="width:1px; height:20px; background:var(--border2);"></div>
|
||||
<button class="btn" type="button" on:click={onDelete}
|
||||
style="color:var(--red); border-color:transparent;"
|
||||
title="Delete this file from local history">
|
||||
<Trash2 size={13} />
|
||||
<span>Delete</span>
|
||||
</button>
|
||||
{:else}
|
||||
<button class="btn" type="button" on:click={onExitEditor}>
|
||||
<ArrowLeft size={13} /><span>Exit Editor</span>
|
||||
</button>
|
||||
<button class="btn" type="button" on:click={onAddChannel}>
|
||||
<Plus size={13} /><span>Add Channel</span>
|
||||
</button>
|
||||
<button class="btn" type="button" on:click={onSaveSnapshot}>
|
||||
<Save size={13} /><span>Snapshot</span>
|
||||
</button>
|
||||
<button class="btn btn-primary" type="button" on:click={onDownload}>
|
||||
<Download size={13} /><span>Export PRS</span>
|
||||
</button>
|
||||
<div style="width:1px; height:20px; background:var(--border2);"></div>
|
||||
<button class="btn" type="button" on:click={onDelete}
|
||||
style="color:var(--red); border-color:transparent;"
|
||||
title="Delete this file from local history">
|
||||
<Trash2 size={13} />
|
||||
<span>Delete</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Right: view toggle + font scale -->
|
||||
<div class="flex items-center gap-2">
|
||||
|
||||
<!-- Font scale control -->
|
||||
<div class="flex items-center" style="border:1px solid var(--border2); border-radius:3px; overflow:hidden;">
|
||||
<button
|
||||
class="btn"
|
||||
style="border:none; border-radius:0; padding:5px 8px;"
|
||||
type="button"
|
||||
disabled={atMin}
|
||||
title="Decrease text size"
|
||||
on:click={onScaleDown}
|
||||
>
|
||||
<ZoomOut size={13} />
|
||||
</button>
|
||||
<button
|
||||
class="btn"
|
||||
style="border:none; border-radius:0; padding:5px 8px; min-width:44px; justify-content:center; font-family:'DM Mono',monospace; font-size:11px; color:var(--text2);"
|
||||
type="button"
|
||||
title="Reset text size"
|
||||
on:click={onScaleReset}
|
||||
>
|
||||
{scalePercent}%
|
||||
</button>
|
||||
<button
|
||||
class="btn"
|
||||
style="border:none; border-radius:0; padding:5px 8px;"
|
||||
type="button"
|
||||
disabled={atMax}
|
||||
title="Increase text size"
|
||||
on:click={onScaleUp}
|
||||
>
|
||||
<ZoomIn size={13} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Separator -->
|
||||
<div style="width:1px; height:20px; background:var(--border2);"></div>
|
||||
|
||||
<!-- View toggle -->
|
||||
<div class="flex" style="border:1px solid var(--border2); border-radius:3px; overflow:hidden;">
|
||||
<button
|
||||
class="btn"
|
||||
style="border:none; border-radius:0; padding:5px 10px; {view === 'cards' ? 'background:var(--amber-dim); color:var(--amber);' : 'background:transparent; color:var(--text3);'}"
|
||||
type="button"
|
||||
on:click={() => onViewChange('cards')}
|
||||
>
|
||||
<LayoutGrid size={13} /><span>Cards</span>
|
||||
</button>
|
||||
<div style="width:1px; background:var(--border2);"></div>
|
||||
<button
|
||||
class="btn"
|
||||
style="border:none; border-radius:0; padding:5px 10px; {view === 'table' ? 'background:var(--amber-dim); color:var(--amber);' : 'background:transparent; color:var(--text3);'}"
|
||||
type="button"
|
||||
on:click={() => onViewChange('table')}
|
||||
>
|
||||
<Table2 size={13} /><span>Table</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
63
src/lib/components/TagInput.svelte
Normal file
63
src/lib/components/TagInput.svelte
Normal file
@@ -0,0 +1,63 @@
|
||||
<script>
|
||||
export let tags = [];
|
||||
export let onChange = () => {};
|
||||
|
||||
let inputValue = '';
|
||||
|
||||
function addTag() {
|
||||
const raw = inputValue.trim().toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
||||
if (!raw || tags.includes(raw) || tags.length >= 10) return;
|
||||
tags = [...tags, raw];
|
||||
inputValue = '';
|
||||
onChange(tags);
|
||||
}
|
||||
|
||||
function removeTag(tag) {
|
||||
tags = tags.filter(t => t !== tag);
|
||||
onChange(tags);
|
||||
}
|
||||
|
||||
function handleKeydown(e) {
|
||||
if (e.key === 'Enter' || e.key === ',') {
|
||||
e.preventDefault();
|
||||
addTag();
|
||||
} else if (e.key === 'Backspace' && !inputValue && tags.length) {
|
||||
removeTag(tags[tags.length - 1]);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div style="display:flex; flex-wrap:wrap; align-items:center; gap:5px; padding:6px 8px;
|
||||
border-radius:3px; border:1px solid var(--border2); background:var(--bg);
|
||||
min-height:38px; cursor:text;"
|
||||
on:click={() => document.getElementById('tag-input-field').focus()}
|
||||
role="none"
|
||||
>
|
||||
{#each tags as tag}
|
||||
<span style="display:inline-flex; align-items:center; gap:4px; padding:2px 8px;
|
||||
border-radius:3px; background:var(--amber-dim); border:1px solid rgba(232,147,10,0.3);
|
||||
font-family:'Barlow Condensed',sans-serif; font-size:12px; font-weight:600;
|
||||
letter-spacing:0.06em; text-transform:uppercase; color:var(--amber);">
|
||||
{tag}
|
||||
<button type="button"
|
||||
style="background:none; border:none; cursor:pointer; color:var(--amber); opacity:0.6;
|
||||
padding:0; line-height:1; font-size:14px;"
|
||||
on:click|stopPropagation={() => removeTag(tag)}>×</button>
|
||||
</span>
|
||||
{/each}
|
||||
|
||||
<input
|
||||
id="tag-input-field"
|
||||
type="text"
|
||||
bind:value={inputValue}
|
||||
on:keydown={handleKeydown}
|
||||
on:blur={addTag}
|
||||
placeholder={tags.length === 0 ? 'Add tags (e.g. wash, moving-head)…' : ''}
|
||||
style="flex:1; min-width:120px; background:none; border:none; outline:none;
|
||||
font-family:'DM Mono',monospace; font-size:12px; color:var(--text);
|
||||
padding:0;"
|
||||
/>
|
||||
</div>
|
||||
<div style="font-family:'DM Mono',monospace; font-size:10px; color:var(--text3); margin-top:4px;">
|
||||
Press Enter or comma to add · {tags.length}/10
|
||||
</div>
|
||||
24
src/lib/components/Toast.svelte
Normal file
24
src/lib/components/Toast.svelte
Normal file
@@ -0,0 +1,24 @@
|
||||
<script>
|
||||
import { CheckCircle } from 'lucide-svelte';
|
||||
|
||||
export let message = '';
|
||||
export let visible = false;
|
||||
</script>
|
||||
|
||||
{#if visible}
|
||||
<div
|
||||
class="fixed bottom-6 left-1/2 z-50 -translate-x-1/2"
|
||||
style="display:flex; align-items:center; gap:8px;
|
||||
padding: 8px 16px;
|
||||
border-radius:3px;
|
||||
border:1px solid rgba(232,147,10,0.5);
|
||||
background: rgba(13,15,14,0.95);
|
||||
box-shadow: 0 0 20px rgba(232,147,10,0.2), 0 8px 32px rgba(0,0,0,0.6);
|
||||
backdrop-filter: blur(12px);"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<CheckCircle size={14} style="color:var(--amber); flex-shrink:0;" />
|
||||
<span style="font-family:'Barlow Condensed',sans-serif; font-weight:600; font-size:12px; letter-spacing:0.08em; text-transform:uppercase; color:var(--amber);">{message}</span>
|
||||
</div>
|
||||
{/if}
|
||||
320
src/lib/prs.js
Normal file
320
src/lib/prs.js
Normal file
@@ -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])];
|
||||
}
|
||||
438
src/lib/server/db.js
Normal file
438
src/lib/server/db.js
Normal file
@@ -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 };
|
||||
43
src/lib/server/manufacturers.js
Normal file
43
src/lib/server/manufacturers.js
Normal file
@@ -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',
|
||||
];
|
||||
55
src/lib/server/ratelimit.js
Normal file
55
src/lib/server/ratelimit.js
Normal file
@@ -0,0 +1,55 @@
|
||||
import { env } from '$env/dynamic/private';
|
||||
|
||||
// In-memory store: Map<ip, { count, windowStart }>
|
||||
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);
|
||||
40
src/lib/server/session.js
Normal file
40
src/lib/server/session.js
Normal file
@@ -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
|
||||
};
|
||||
}
|
||||
12
src/lib/shared/slugify.js
Normal file
12
src/lib/shared/slugify.js
Normal file
@@ -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';
|
||||
}
|
||||
110
src/lib/storage.js
Normal file
110
src/lib/storage.js
Normal file
@@ -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;
|
||||
}
|
||||
13
src/lib/utils/clickOutside.js
Normal file
13
src/lib/utils/clickOutside.js
Normal file
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
9
src/routes/+layout.svelte
Normal file
9
src/routes/+layout.svelte
Normal file
@@ -0,0 +1,9 @@
|
||||
<script>
|
||||
import '../app.css';
|
||||
import Footer from '$lib/components/Footer.svelte';
|
||||
</script>
|
||||
|
||||
<div style="min-height:100vh; display:flex; flex-direction:column; width:100%;">
|
||||
<slot />
|
||||
<Footer />
|
||||
</div>
|
||||
945
src/routes/+page.svelte
Normal file
945
src/routes/+page.svelte
Normal file
@@ -0,0 +1,945 @@
|
||||
<script>
|
||||
import { browser } from '$app/environment';
|
||||
import { BookOpen, FileStack, AlertCircle, FolderOpen, Layers3, HardDriveUpload, Trash2, Table2 } from 'lucide-svelte';
|
||||
import GlobalMenu from '$lib/components/GlobalMenu.svelte';
|
||||
import SecondaryBar from '$lib/components/SecondaryBar.svelte';
|
||||
import ChannelCardGrid from '$lib/components/ChannelCardGrid.svelte';
|
||||
import ChannelTable from '$lib/components/ChannelTable.svelte';
|
||||
import Toast from '$lib/components/Toast.svelte';
|
||||
import PublishModal from '$lib/components/PublishModal.svelte';
|
||||
import PublishSuccessModal from '$lib/components/PublishSuccessModal.svelte';
|
||||
import ClearDataModal from '$lib/components/ClearDataModal.svelte';
|
||||
import {
|
||||
ATTRIBUTE_NAMES,
|
||||
NAME_LEN,
|
||||
addChannel,
|
||||
buildPRS,
|
||||
buildVisibleEntries,
|
||||
createBlankPersonality,
|
||||
deleteEntry,
|
||||
normalizePersonality,
|
||||
parsePRS,
|
||||
reorderEntries,
|
||||
toggle16BitPair,
|
||||
updateEntry
|
||||
} from '$lib/prs';
|
||||
import { getSnapshots, getStoredFiles, saveSnapshot, saveStoredFile, attachSharedRecord, FILES_STORAGE_KEY, SNAPSHOT_STORAGE_KEY } from '$lib/storage';
|
||||
import { makeSlug } from '$lib/shared/slugify.js';
|
||||
import { cryptoLikeId } from '$lib/prs';
|
||||
|
||||
const VIEW_STORAGE_KEY = 'etc-prs-ui-view-mode-v1';
|
||||
const AUTOSAVE_KEY = 'etc-prs-ui-autosave-v1';
|
||||
const SCALE_STORAGE_KEY = 'etc-prs-ui-scale-v1';
|
||||
|
||||
const SCALE_MIN = 0.6;
|
||||
const SCALE_MAX = 1.4;
|
||||
const SCALE_STEP = 0.1;
|
||||
const SCALE_DEFAULT = 1.0;
|
||||
|
||||
let mode = 'root';
|
||||
let view = 'cards';
|
||||
let currentFileId = null;
|
||||
let personality = createBlankPersonality();
|
||||
let displayName = ''; // unconstrained library/display name
|
||||
let displayNameEdited = false; // true once user has manually changed it
|
||||
let previousFiles = [];
|
||||
let snapshots = [];
|
||||
let draggingKey = null;
|
||||
let dropTarget = null;
|
||||
let error = '';
|
||||
let showNewFileModal = false;
|
||||
let newFileName = '';
|
||||
let notes = '';
|
||||
let showPublishModal = false;
|
||||
let publishResult = null;
|
||||
let showClearModal = false;
|
||||
let latestItems = [];
|
||||
let latestLoading = true;
|
||||
|
||||
async function fetchLatest() {
|
||||
try {
|
||||
const res = await fetch('/api/library?limit=5&sort=newest');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
latestItems = data.items ?? [];
|
||||
}
|
||||
} catch { /* non-fatal — section just stays empty */ }
|
||||
latestLoading = false;
|
||||
}
|
||||
|
||||
if (browser) fetchLatest();
|
||||
let uiScale = SCALE_DEFAULT;
|
||||
let toastVisible = false;
|
||||
let toastMessage = '';
|
||||
let toastTimer;
|
||||
|
||||
function showToast(message) {
|
||||
toastMessage = message;
|
||||
toastVisible = true;
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => { toastVisible = false; }, 2500);
|
||||
}
|
||||
|
||||
$: normalized = normalizePersonality(personality);
|
||||
$: visibleEntries = buildVisibleEntries(normalized.channels);
|
||||
$: totalChannelCount = normalized.channels.length;
|
||||
$: pairCount = visibleEntries.filter((entry) => entry.isPair).length;
|
||||
|
||||
function refreshStorageLists() {
|
||||
if (!browser) return;
|
||||
previousFiles = getStoredFiles();
|
||||
snapshots = currentFileId ? getSnapshots(currentFileId) : [];
|
||||
}
|
||||
|
||||
if (browser) {
|
||||
view = localStorage.getItem(VIEW_STORAGE_KEY) || 'cards';
|
||||
const savedScale = parseFloat(localStorage.getItem(SCALE_STORAGE_KEY));
|
||||
if (!isNaN(savedScale)) uiScale = Math.min(SCALE_MAX, Math.max(SCALE_MIN, savedScale));
|
||||
const autosaved = localStorage.getItem(AUTOSAVE_KEY);
|
||||
if (autosaved) {
|
||||
try {
|
||||
const parsed = JSON.parse(autosaved);
|
||||
personality = normalizePersonality(parsed.personality);
|
||||
mode = parsed.mode ?? 'viewer';
|
||||
currentFileId = parsed.currentFileId ?? null;
|
||||
notes = parsed.notes ?? '';
|
||||
} catch {
|
||||
// ignore corrupt autosave
|
||||
}
|
||||
}
|
||||
refreshStorageLists();
|
||||
}
|
||||
|
||||
// On mount, enrich any token-only stubs from a previous data clear
|
||||
if (browser) {
|
||||
enrichTokenStubs();
|
||||
}
|
||||
|
||||
$: if (browser) {
|
||||
localStorage.setItem(VIEW_STORAGE_KEY, view);
|
||||
}
|
||||
|
||||
$: if (browser) {
|
||||
localStorage.setItem(SCALE_STORAGE_KEY, String(uiScale));
|
||||
}
|
||||
|
||||
function scaleUp() { uiScale = Math.min(SCALE_MAX, +(uiScale + SCALE_STEP).toFixed(1)); }
|
||||
function scaleDown() { uiScale = Math.max(SCALE_MIN, +(uiScale - SCALE_STEP).toFixed(1)); }
|
||||
function scaleReset(){ uiScale = SCALE_DEFAULT; }
|
||||
|
||||
// Autosave personality state on every change (only when a file is active)
|
||||
$: if (browser && mode !== 'root') {
|
||||
localStorage.setItem(AUTOSAVE_KEY, JSON.stringify({ personality: normalized, mode, currentFileId, notes }));
|
||||
}
|
||||
|
||||
async function handleOpenFile(event) {
|
||||
const file = event.currentTarget.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
error = '';
|
||||
const buffer = await file.arrayBuffer();
|
||||
personality = normalizePersonality(parsePRS(buffer));
|
||||
displayName = personality.name;
|
||||
displayNameEdited = false;
|
||||
const stored = saveStoredFile(personality, file.name);
|
||||
currentFileId = stored.id;
|
||||
notes = stored.notes ?? '';
|
||||
mode = 'viewer';
|
||||
refreshStorageLists();
|
||||
} catch (err) {
|
||||
error = err?.message ?? String(err);
|
||||
} finally {
|
||||
event.currentTarget.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function openStoredFile(item) {
|
||||
error = '';
|
||||
|
||||
if (item.shared_id && (!item.parsed?.channels?.length)) {
|
||||
try {
|
||||
const res = await fetch(`/api/personality/${item.shared_id}/download`);
|
||||
if (res.ok) {
|
||||
const buf = await res.arrayBuffer();
|
||||
const parsed = normalizePersonality(parsePRS(buf));
|
||||
saveStoredFile(parsed, item.fileName, item.notes ?? '');
|
||||
const stored = saveStoredFile(parsed, item.fileName, item.notes ?? '');
|
||||
personality = parsed;
|
||||
displayName = item.displayName || parsed.name;
|
||||
displayNameEdited = !!item.displayName && item.displayName !== parsed.name;
|
||||
currentFileId = stored.id;
|
||||
notes = item.notes ?? '';
|
||||
mode = 'viewer';
|
||||
refreshStorageLists();
|
||||
return;
|
||||
}
|
||||
} catch { /* fall through */ }
|
||||
}
|
||||
|
||||
personality = normalizePersonality(item.parsed);
|
||||
displayName = item.displayName || personality.name;
|
||||
displayNameEdited = !!item.displayName && item.displayName !== personality.name;
|
||||
currentFileId = item.id;
|
||||
notes = item.notes ?? '';
|
||||
mode = 'viewer';
|
||||
saveStoredFile(personality, item.fileName, item.notes ?? '');
|
||||
refreshStorageLists();
|
||||
}
|
||||
|
||||
function openSnapshot(item) {
|
||||
personality = normalizePersonality(item.parsed);
|
||||
mode = 'editor';
|
||||
error = '';
|
||||
}
|
||||
|
||||
function startNewFile() {
|
||||
newFileName = '';
|
||||
showNewFileModal = true;
|
||||
}
|
||||
|
||||
function confirmNewFile() {
|
||||
personality = { ...createBlankPersonality(), name: newFileName.slice(0, NAME_LEN) };
|
||||
displayName = newFileName; // display name starts as full typed name (may exceed 12 chars)
|
||||
displayNameEdited = false;
|
||||
currentFileId = null;
|
||||
notes = '';
|
||||
mode = 'editor';
|
||||
error = '';
|
||||
showNewFileModal = false;
|
||||
}
|
||||
|
||||
function cancelNewFile() {
|
||||
showNewFileModal = false;
|
||||
}
|
||||
|
||||
function enterEditor() {
|
||||
mode = 'editor';
|
||||
const stored = saveStoredFile(normalized);
|
||||
currentFileId = stored.id;
|
||||
refreshStorageLists();
|
||||
}
|
||||
|
||||
function exitEditor() {
|
||||
const stored = saveStoredFile(normalized);
|
||||
currentFileId = stored.id;
|
||||
mode = 'viewer';
|
||||
refreshStorageLists();
|
||||
}
|
||||
|
||||
function isBlankPersonality(p) {
|
||||
// A file is considered blank if it has no name and no channels
|
||||
return (!p.name || p.name.trim() === '') && (!p.channels || p.channels.length === 0);
|
||||
}
|
||||
|
||||
function pruneCurrentIfBlank() {
|
||||
if (currentFileId && isBlankPersonality(normalized)) {
|
||||
const files = getStoredFiles();
|
||||
const updated = files.filter(f => f.id !== currentFileId);
|
||||
localStorage.setItem(FILES_STORAGE_KEY, JSON.stringify(updated));
|
||||
}
|
||||
}
|
||||
|
||||
function goHome() {
|
||||
pruneCurrentIfBlank();
|
||||
mode = 'root';
|
||||
currentFileId = null;
|
||||
personality = createBlankPersonality();
|
||||
displayName = '';
|
||||
displayNameEdited = false;
|
||||
notes = '';
|
||||
if (browser) localStorage.removeItem(AUTOSAVE_KEY);
|
||||
refreshStorageLists();
|
||||
}
|
||||
|
||||
function handleDeleteFile() {
|
||||
if (!confirm('Delete this file from local history? This cannot be undone.')) return;
|
||||
if (currentFileId) {
|
||||
const files = getStoredFiles();
|
||||
const updated = files.filter(f => f.id !== currentFileId);
|
||||
localStorage.setItem(FILES_STORAGE_KEY, JSON.stringify(updated));
|
||||
localStorage.removeItem(`${SNAPSHOT_STORAGE_KEY}:${currentFileId}`);
|
||||
}
|
||||
currentFileId = null;
|
||||
personality = createBlankPersonality();
|
||||
displayName = '';
|
||||
displayNameEdited = false;
|
||||
notes = '';
|
||||
mode = 'root';
|
||||
if (browser) localStorage.removeItem(AUTOSAVE_KEY);
|
||||
refreshStorageLists();
|
||||
showToast('File deleted from local history');
|
||||
}
|
||||
|
||||
function updatePersonalityName(value) {
|
||||
personality = { ...normalized, name: value.slice(0, NAME_LEN) };
|
||||
// Keep display name in sync until user manually overrides it
|
||||
if (!displayNameEdited) {
|
||||
displayName = value;
|
||||
}
|
||||
}
|
||||
|
||||
function updateDisplayName(value) {
|
||||
displayName = value;
|
||||
displayNameEdited = value.trim() !== personality.name.trim();
|
||||
}
|
||||
|
||||
function applyPatch(entryKey, patch) {
|
||||
personality = {
|
||||
...normalized,
|
||||
channels: updateEntry(normalized.channels, entryKey, patch)
|
||||
};
|
||||
}
|
||||
|
||||
function handleTogglePair(entryKey, enabled) {
|
||||
personality = {
|
||||
...normalized,
|
||||
channels: toggle16BitPair(normalized.channels, entryKey, enabled)
|
||||
};
|
||||
}
|
||||
|
||||
function handleAddChannel() {
|
||||
personality = {
|
||||
...normalized,
|
||||
channels: addChannel(normalized.channels)
|
||||
};
|
||||
}
|
||||
|
||||
function handleDeleteEntry(entryKey) {
|
||||
personality = {
|
||||
...normalized,
|
||||
channels: deleteEntry(normalized.channels, entryKey)
|
||||
};
|
||||
}
|
||||
|
||||
function handleDragStart(entryKey) {
|
||||
draggingKey = entryKey;
|
||||
}
|
||||
|
||||
function getDropPosition(targetKey, clientY) {
|
||||
if (!browser) return 'after';
|
||||
const element = document.querySelector(`[data-entry-key="${targetKey}"]`);
|
||||
if (!element) return 'after';
|
||||
const rect = element.getBoundingClientRect();
|
||||
return clientY <= rect.top + rect.height / 2 ? 'before' : 'after';
|
||||
}
|
||||
|
||||
function handleDrop(targetKey, clientY, commit = false) {
|
||||
if (!draggingKey || draggingKey === targetKey) return;
|
||||
const position = getDropPosition(targetKey, clientY);
|
||||
dropTarget = { key: targetKey, position };
|
||||
|
||||
if (!commit) return;
|
||||
|
||||
personality = {
|
||||
...normalized,
|
||||
channels: reorderEntries(normalized.channels, draggingKey, targetKey, position)
|
||||
};
|
||||
draggingKey = null;
|
||||
dropTarget = null;
|
||||
}
|
||||
|
||||
function handleDragEnd() {
|
||||
draggingKey = null;
|
||||
dropTarget = null;
|
||||
}
|
||||
|
||||
function handleClearData(keepTokens) {
|
||||
if (!browser) return;
|
||||
|
||||
if (keepTokens) {
|
||||
const files = getStoredFiles();
|
||||
const tokens = files
|
||||
.filter(f => f.shared_id && f.owner_token)
|
||||
.map(f => ({ shared_id: f.shared_id, owner_token: f.owner_token, shared_url: f.shared_url }));
|
||||
|
||||
localStorage.removeItem(FILES_STORAGE_KEY);
|
||||
localStorage.removeItem(SNAPSHOT_STORAGE_KEY);
|
||||
localStorage.removeItem(AUTOSAVE_KEY);
|
||||
|
||||
if (tokens.length) {
|
||||
// Write minimal stubs immediately so tokens aren't lost
|
||||
const stubs = tokens.map(t => ({
|
||||
id: cryptoLikeId('file'),
|
||||
name: '(loading…)',
|
||||
fileName: null,
|
||||
notes: '',
|
||||
channelCount: 0,
|
||||
signature: '',
|
||||
parsed: { name: '', channelCount: 0, channels: [] },
|
||||
lastOpenedAt: new Date().toISOString(),
|
||||
createdAt: new Date().toISOString(),
|
||||
shared_id: t.shared_id,
|
||||
shared_url: t.shared_url,
|
||||
owner_token: t.owner_token,
|
||||
}));
|
||||
localStorage.setItem(FILES_STORAGE_KEY, JSON.stringify(stubs));
|
||||
|
||||
// Then enrich stubs with real metadata from the library API
|
||||
enrichTokenStubs();
|
||||
}
|
||||
} else {
|
||||
localStorage.removeItem(FILES_STORAGE_KEY);
|
||||
localStorage.removeItem(SNAPSHOT_STORAGE_KEY);
|
||||
localStorage.removeItem(AUTOSAVE_KEY);
|
||||
}
|
||||
|
||||
personality = createBlankPersonality();
|
||||
currentFileId = null;
|
||||
notes = '';
|
||||
mode = 'root';
|
||||
showClearModal = false;
|
||||
refreshStorageLists();
|
||||
showToast('Local data cleared');
|
||||
}
|
||||
|
||||
// Fetch real metadata from the library API for any stub records
|
||||
// (name === '(loading…)' or channelCount === 0 with a shared_id).
|
||||
// Safe to call multiple times — skips already-enriched records.
|
||||
async function enrichTokenStubs() {
|
||||
if (!browser) return;
|
||||
const files = getStoredFiles();
|
||||
const stubs = files.filter(f => f.shared_id && f.channelCount === 0);
|
||||
if (!stubs.length) return;
|
||||
|
||||
const enriched = await Promise.all(stubs.map(async stub => {
|
||||
try {
|
||||
const res = await fetch(`/api/personality/${stub.shared_id}`);
|
||||
if (!res.ok) return stub;
|
||||
const p = await res.json();
|
||||
return {
|
||||
...stub,
|
||||
name: p.name || stub.name,
|
||||
fileName: p.file_name || null,
|
||||
channelCount: p.channel_count || 0,
|
||||
notes: p.notes || '',
|
||||
// Rebuild a minimal parsed object so "Load into editor" works after enrichment
|
||||
parsed: {
|
||||
name: p.name || '',
|
||||
channelCount: p.channel_count || 0,
|
||||
channels: []
|
||||
}
|
||||
};
|
||||
} catch {
|
||||
return stub;
|
||||
}
|
||||
}));
|
||||
|
||||
// Merge enriched stubs back into the full file list
|
||||
const current = getStoredFiles();
|
||||
const updated = current.map(f => {
|
||||
const match = enriched.find(e => e.id === f.id);
|
||||
return match ?? f;
|
||||
});
|
||||
localStorage.setItem(FILES_STORAGE_KEY, JSON.stringify(updated));
|
||||
refreshStorageLists();
|
||||
}
|
||||
|
||||
function saveNotes(value) {
|
||||
notes = value;
|
||||
if (currentFileId) {
|
||||
const files = getStoredFiles();
|
||||
const record = files.find((f) => f.id === currentFileId);
|
||||
if (record) {
|
||||
saveStoredFile(record.parsed, record.fileName, value);
|
||||
refreshStorageLists();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePublish({ manufacturer, tags, creator_handle, library_name }) {
|
||||
const norm = normalizePersonality(personality);
|
||||
const bytes = buildPRS(norm);
|
||||
|
||||
const res = await fetch('/api/share', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
data: Array.from(bytes),
|
||||
name: library_name || displayName || norm.name,
|
||||
file_name: personality.fileName ?? null,
|
||||
notes: notes ?? '',
|
||||
manufacturer,
|
||||
tags,
|
||||
creator_handle
|
||||
})
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.message ?? `Server error ${res.status}`);
|
||||
}
|
||||
|
||||
const result = await res.json();
|
||||
publishResult = result;
|
||||
showPublishModal = false;
|
||||
|
||||
// Always save the file first so we have a guaranteed, current local record
|
||||
// then attach the share metadata (token + URL) to that record's ID.
|
||||
const stored = saveStoredFile(norm, personality.fileName ?? null, notes ?? null);
|
||||
currentFileId = stored.id;
|
||||
attachSharedRecord(stored.id, result);
|
||||
|
||||
refreshStorageLists();
|
||||
}
|
||||
|
||||
function handleSaveSnapshot() {
|
||||
const stored = saveStoredFile(normalized);
|
||||
currentFileId = stored.id;
|
||||
saveSnapshot(stored.id, normalized, `Snapshot ${new Date().toLocaleString()}`);
|
||||
refreshStorageLists();
|
||||
showToast('Snapshot saved');
|
||||
}
|
||||
|
||||
function handleDownload() {
|
||||
try {
|
||||
const stored = saveStoredFile(normalized);
|
||||
currentFileId = stored.id;
|
||||
const bytes = buildPRS(normalized);
|
||||
const blob = new Blob([bytes], { type: 'application/octet-stream' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = `${(normalized.name || 'personality').replace(/[^A-Za-z0-9_-]/g, '_')}.prs`;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
refreshStorageLists();
|
||||
} catch (err) {
|
||||
error = err?.message ?? String(err);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>ETC PRS Viewer & Editor</title>
|
||||
</svelte:head>
|
||||
|
||||
<GlobalMenu
|
||||
{previousFiles}
|
||||
{snapshots}
|
||||
{currentFileId}
|
||||
currentMode={mode}
|
||||
onOpenFile={handleOpenFile}
|
||||
onNewFile={startNewFile}
|
||||
onOpenStoredFile={openStoredFile}
|
||||
onOpenSnapshot={openSnapshot}
|
||||
onGoHome={goHome}
|
||||
/>
|
||||
|
||||
<div style="width:100%; max-width:1400px; margin:0 auto; padding:20px; box-sizing:border-box;">
|
||||
|
||||
{#if error}
|
||||
<div class="mb-4 flex items-start gap-3 p-4" style="border-radius:4px; border:1px solid rgba(248,113,113,0.35); background:var(--red-dim); color:var(--red);">
|
||||
<AlertCircle class="mt-0.5 shrink-0" size={16} />
|
||||
<div style="font-family:'DM Mono',monospace; font-size:12px;">{error}</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if mode === 'root'}
|
||||
<section class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_300px]">
|
||||
<div class="panel p-5">
|
||||
<!-- Header -->
|
||||
<div class="mb-5 flex items-center gap-3" style="border-bottom:1px solid var(--border); padding-bottom:16px;">
|
||||
<BookOpen size={20} style="color:var(--amber); flex-shrink:0;" />
|
||||
<div>
|
||||
<h1 style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:20px; letter-spacing:0.06em; text-transform:uppercase; color:var(--text);">ETC PRS Viewer & Editor</h1>
|
||||
<p class="subtle mt-0.5">Open a PRS file or start a blank personality.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mini stat strip -->
|
||||
<div class="mb-5 grid grid-cols-3 gap-3">
|
||||
<div style="padding:12px; border-radius:3px; border:1px solid var(--border); background:var(--raised);">
|
||||
<div class="label">Stored Files</div>
|
||||
<div class="led-readout mt-1.5">{previousFiles.length}</div>
|
||||
</div>
|
||||
<div style="padding:12px; border-radius:3px; border:1px solid var(--border); background:var(--raised);">
|
||||
<div class="label">Snapshots</div>
|
||||
<div class="led-readout cyan mt-1.5">{snapshots.length}</div>
|
||||
</div>
|
||||
<div style="padding:12px; border-radius:3px; border:1px solid var(--border); background:var(--raised);">
|
||||
<div class="label">Quick Start</div>
|
||||
<div class="subtle mt-1.5">Use top bar to open files.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Previously opened -->
|
||||
<div>
|
||||
<h2 style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:12px; letter-spacing:0.12em; text-transform:uppercase; color:var(--text3); margin-bottom:10px;">Previously Opened</h2>
|
||||
{#if previousFiles.length}
|
||||
<div class="grid gap-2 md:grid-cols-2 xl:grid-cols-3">
|
||||
{#each previousFiles as file}
|
||||
<button
|
||||
class="panel text-left transition"
|
||||
style="padding:12px; display:block;"
|
||||
type="button"
|
||||
on:click={() => openStoredFile(file)}
|
||||
on:mouseenter={(e) => { e.currentTarget.style.borderColor='var(--border2)'; }}
|
||||
on:mouseleave={(e) => { e.currentTarget.style.borderColor='var(--border)'; }}
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="min-w-0">
|
||||
<div style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:14px; letter-spacing:0.03em; color:var(--text);">{file.name}</div>
|
||||
{#if file.fileName}
|
||||
<div style="font-family:'DM Mono',monospace; font-size:10px; color:var(--text3); margin-top:2px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">{file.fileName}</div>
|
||||
{/if}
|
||||
<div class="subtle mt-1">{file.channelCount} channels</div>
|
||||
</div>
|
||||
<div style="display:flex; flex-direction:column; align-items:flex-end; gap:4px; flex-shrink:0;">
|
||||
<FileStack size={16} style="color:var(--text3);" />
|
||||
{#if file.shared_id}
|
||||
<span style="font-family:'Barlow Condensed',sans-serif; font-size:10px; font-weight:700;
|
||||
letter-spacing:0.08em; text-transform:uppercase;
|
||||
color:var(--cyan); border:1px solid rgba(45,212,200,0.3);
|
||||
background:var(--cyan-dim); padding:1px 5px; border-radius:2px;">
|
||||
Published
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if file.notes}
|
||||
<div style="margin-top:6px; font-size:11px; color:var(--text3); font-style:italic; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">{file.notes}</div>
|
||||
{/if}
|
||||
{#if file.shared_url}
|
||||
<!-- Stop propagation so clicking the link doesn't also open the file -->
|
||||
<a
|
||||
href={file.shared_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style="display:inline-flex; align-items:center; gap:4px; margin-top:6px;
|
||||
font-family:'DM Mono',monospace; font-size:10px; color:var(--cyan);
|
||||
text-decoration:none;"
|
||||
on:click|stopPropagation
|
||||
>
|
||||
View in library ↗
|
||||
</a>
|
||||
{/if}
|
||||
<div class="subtle mt-2" style="font-family:'DM Mono',monospace; font-size:10px;">Last: {new Date(file.lastOpenedAt).toLocaleString()}</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="subtle">No stored files yet.</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="grid gap-3 content-start">
|
||||
|
||||
<!-- Latest Library Additions -->
|
||||
<div class="panel" style="overflow:hidden;">
|
||||
<div style="padding:12px 14px 10px; border-bottom:1px solid var(--border); display:flex; align-items:center; justify-content:space-between; gap:8px;">
|
||||
<div style="display:flex; align-items:center; gap:8px;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="color:var(--amber); flex-shrink:0;"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>
|
||||
<div style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:13px; letter-spacing:0.08em; text-transform:uppercase; color:var(--text);">Latest from Library</div>
|
||||
</div>
|
||||
<a href="/library" style="font-family:'Barlow Condensed',sans-serif; font-size:11px; font-weight:600; letter-spacing:0.06em; text-transform:uppercase; color:var(--text3); text-decoration:none;"
|
||||
on:mouseenter={(e) => e.currentTarget.style.color='var(--amber)'}
|
||||
on:mouseleave={(e) => e.currentTarget.style.color='var(--text3)'}>
|
||||
See all →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{#if latestLoading}
|
||||
<div style="padding:20px 14px; font-family:'DM Mono',monospace; font-size:11px; color:var(--text3);">Loading…</div>
|
||||
{:else if latestItems.length === 0}
|
||||
<div style="padding:20px 14px; font-family:'DM Mono',monospace; font-size:11px; color:var(--text3);">
|
||||
No personalities published yet.<br/>Be the first!
|
||||
</div>
|
||||
{:else}
|
||||
{#each latestItems as item, i}
|
||||
<a
|
||||
href="/p/{item.id}/{makeSlug(item.name)}"
|
||||
style="display:block; padding:10px 14px; text-decoration:none;
|
||||
border-bottom:{i < latestItems.length - 1 ? '1px solid var(--border)' : 'none'};
|
||||
transition:background 0.1s;"
|
||||
on:mouseenter={(e) => e.currentTarget.style.background='var(--raised)'}
|
||||
on:mouseleave={(e) => e.currentTarget.style.background=''}
|
||||
>
|
||||
<div style="display:flex; align-items:flex-start; justify-content:space-between; gap:8px;">
|
||||
<div style="min-width:0;">
|
||||
<div style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:14px;
|
||||
letter-spacing:0.03em; color:var(--text); white-space:nowrap;
|
||||
overflow:hidden; text-overflow:ellipsis;">
|
||||
{item.name}
|
||||
</div>
|
||||
{#if item.manufacturer}
|
||||
<div style="font-family:'DM Mono',monospace; font-size:10px; color:var(--text3); margin-top:1px;">
|
||||
{item.manufacturer}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div style="font-family:'DM Mono',monospace; font-size:13px; font-weight:500;
|
||||
color:{item.channel_count >= 12 ? 'var(--cyan)' : 'var(--amber)'};
|
||||
flex-shrink:0; line-height:1.4;">
|
||||
{item.channel_count}<span style="font-size:9px; color:var(--text3); margin-left:2px;">CH</span>
|
||||
</div>
|
||||
</div>
|
||||
{#if item.tags?.length}
|
||||
<div style="display:flex; flex-wrap:wrap; gap:3px; margin-top:5px;">
|
||||
{#each item.tags.slice(0, 3) as tag}
|
||||
<span class="badge" style="font-size:10px; padding:1px 5px;">{tag}</span>
|
||||
{/each}
|
||||
{#if item.tags.length > 3}
|
||||
<span style="font-size:10px; color:var(--text3); font-family:'DM Mono',monospace;">+{item.tags.length - 3}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Info cards -->
|
||||
<div class="panel p-4">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<FolderOpen size={15} style="color:var(--amber);" />
|
||||
<div style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:13px; letter-spacing:0.06em; text-transform:uppercase; color:var(--text);">Opening files</div>
|
||||
</div>
|
||||
<p class="subtle">Use the <span style="color:var(--text);">File ▾</span> menu in the top bar to open a PRS from disk, start a new personality, or reopen a recent file.</p>
|
||||
</div>
|
||||
<div class="panel p-4">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<HardDriveUpload size={15} style="color:var(--amber);" />
|
||||
<div style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:13px; letter-spacing:0.06em; text-transform:uppercase; color:var(--text);">Local history</div>
|
||||
</div>
|
||||
<p class="subtle">Files and snapshots are saved in your browser. Use <span style="color:var(--text);">Delete</span> in the viewer or editor to remove a file from history.</p>
|
||||
</div>
|
||||
<div class="panel p-4">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<Table2 size={15} style="color:var(--amber);" />
|
||||
<div style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:13px; letter-spacing:0.06em; text-transform:uppercase; color:var(--text);">Two views</div>
|
||||
</div>
|
||||
<p class="subtle">Card view for editing and detail. Table view for a compact overview. Toggle in the toolbar — preference is remembered.</p>
|
||||
</div>
|
||||
|
||||
<!-- Clear local data -->
|
||||
<div class="panel p-4" style="border-color:var(--border);">
|
||||
<div class="flex items-center justify-between gap-2 mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="color:var(--text3); flex-shrink:0;"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>
|
||||
<div style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:13px; letter-spacing:0.06em; text-transform:uppercase; color:var(--text);">Local data</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="subtle" style="margin-bottom:10px;">Clear your file history, snapshots, and autosave from this browser.</p>
|
||||
<button
|
||||
class="btn btn-danger"
|
||||
style="width:100%; justify-content:center; font-size:12px;"
|
||||
type="button"
|
||||
on:click={() => showClearModal = true}
|
||||
>
|
||||
Clear local data…
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
{:else}
|
||||
<section class="panel mb-4" style="padding:0; overflow:hidden;">
|
||||
<!-- Name fields + stat cells -->
|
||||
<div class="grid gap-0" style="grid-template-columns:minmax(0,1fr) minmax(0,1fr) repeat(3,130px); border-bottom:1px solid var(--border);">
|
||||
|
||||
<!-- PRS Name (12-char binary limit) -->
|
||||
<div style="padding:12px 16px; border-right:1px solid var(--border);">
|
||||
<label class="label" for="fixtureName" style="display:flex; align-items:center; gap:6px;">
|
||||
PRS Name
|
||||
<span style="font-family:'DM Mono',monospace; font-size:10px; font-weight:400;
|
||||
text-transform:none; letter-spacing:0; color:var(--text3);">
|
||||
({NAME_LEN} char max)
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
id="fixtureName"
|
||||
class="input mt-1.5"
|
||||
style="font-family:'DM Mono',monospace; font-size:15px; font-weight:600; letter-spacing:0.04em;"
|
||||
type="text"
|
||||
maxlength={NAME_LEN}
|
||||
bind:value={personality.name}
|
||||
on:input={(e) => updatePersonalityName(e.currentTarget.value)}
|
||||
/>
|
||||
<div style="font-family:'DM Mono',monospace; font-size:10px; color:var(--text3); margin-top:4px; text-align:right;">
|
||||
{personality.name.length}/{NAME_LEN}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Display / Library Name (unconstrained) -->
|
||||
<div style="padding:12px 16px; border-right:1px solid var(--border);">
|
||||
<label class="label" for="displayName" style="display:flex; align-items:center; gap:6px;">
|
||||
Library Name
|
||||
<span style="font-family:'DM Mono',monospace; font-size:10px; font-weight:400;
|
||||
text-transform:none; letter-spacing:0; color:var(--text3);">
|
||||
(used when publishing)
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
id="displayName"
|
||||
class="input mt-1.5"
|
||||
style="font-family:'Barlow Condensed',sans-serif; font-size:16px; font-weight:700; letter-spacing:0.04em;"
|
||||
type="text"
|
||||
maxlength="120"
|
||||
bind:value={displayName}
|
||||
placeholder={personality.name || 'Display name…'}
|
||||
on:input={(e) => updateDisplayName(e.currentTarget.value)}
|
||||
/>
|
||||
{#if !displayNameEdited && personality.name}
|
||||
<div style="font-family:'DM Mono',monospace; font-size:10px; color:var(--text3); margin-top:4px;">
|
||||
Auto-synced from PRS name
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div style="padding:12px 16px; border-right:1px solid var(--border); display:flex; flex-direction:column; gap:4px;">
|
||||
<div class="label">Channels</div>
|
||||
<div class="led-readout">{totalChannelCount}</div>
|
||||
</div>
|
||||
<div style="padding:12px 16px; border-right:1px solid var(--border); display:flex; flex-direction:column; gap:4px;">
|
||||
<div class="label">Entries</div>
|
||||
<div class="led-readout cyan">{visibleEntries.length}</div>
|
||||
</div>
|
||||
<div style="padding:12px 16px; display:flex; flex-direction:column; gap:4px;">
|
||||
<div class="label">16-bit Pairs</div>
|
||||
<div class="led-readout green">{pairCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Notes row -->
|
||||
<div style="padding:10px 16px;">
|
||||
<label class="label" for="fileNotes" style="display:inline-flex; align-items:center; gap:6px;">
|
||||
Notes
|
||||
<span style="font-family:'DM Mono',monospace; font-size:10px; letter-spacing:0.04em; color:var(--text3); text-transform:none; font-weight:400;">(not exported to PRS)</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="fileNotes"
|
||||
class="input mt-1.5 resize-none"
|
||||
style="font-size:14px; line-height:1.5;"
|
||||
rows="2"
|
||||
placeholder="Add any notes about this personality file…"
|
||||
value={notes}
|
||||
on:change={(e) => saveNotes(e.currentTarget.value)}
|
||||
></textarea>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if mode !== 'root'}
|
||||
<SecondaryBar
|
||||
{mode}
|
||||
{view}
|
||||
{uiScale}
|
||||
{SCALE_MIN}
|
||||
{SCALE_MAX}
|
||||
onViewChange={(next) => (view = next)}
|
||||
onOpenEditor={enterEditor}
|
||||
onExitEditor={exitEditor}
|
||||
onPublish={() => (showPublishModal = true)}
|
||||
onAddChannel={handleAddChannel}
|
||||
onSaveSnapshot={handleSaveSnapshot}
|
||||
onDownload={handleDownload}
|
||||
onDelete={handleDeleteFile}
|
||||
onScaleUp={scaleUp}
|
||||
onScaleDown={scaleDown}
|
||||
onScaleReset={scaleReset}
|
||||
/>
|
||||
|
||||
<div style="width:100%; max-width:1400px; margin:0 auto; padding:0 20px 20px; box-sizing:border-box;">
|
||||
<div style="font-size:{uiScale}rem;">
|
||||
{#if view === 'cards'}
|
||||
<ChannelCardGrid
|
||||
entries={visibleEntries}
|
||||
editable={mode === 'editor'}
|
||||
attributes={ATTRIBUTE_NAMES}
|
||||
{draggingKey}
|
||||
{dropTarget}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDrop={handleDrop}
|
||||
onUpdate={applyPatch}
|
||||
onDelete={handleDeleteEntry}
|
||||
onTogglePair={handleTogglePair}
|
||||
/>
|
||||
{:else}
|
||||
<ChannelTable
|
||||
entries={visibleEntries}
|
||||
editable={mode === 'editor'}
|
||||
attributes={ATTRIBUTE_NAMES}
|
||||
{draggingKey}
|
||||
{dropTarget}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDrop={handleDrop}
|
||||
onUpdate={applyPatch}
|
||||
onDelete={handleDeleteEntry}
|
||||
onTogglePair={handleTogglePair}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Toast message={toastMessage} visible={toastVisible} />
|
||||
|
||||
{#if showNewFileModal}
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center backdrop-blur-sm"
|
||||
style="background:rgba(0,0,0,0.75);"
|
||||
role="presentation"
|
||||
on:click|self={cancelNewFile}
|
||||
on:keydown={(e) => { if (e.key === 'Escape') cancelNewFile(); }}
|
||||
>
|
||||
<div
|
||||
class="panel w-full max-w-sm"
|
||||
style="padding:24px; box-shadow:0 0 0 1px rgba(232,147,10,0.15), 0 24px 64px rgba(0,0,0,0.8);"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="new-file-title"
|
||||
>
|
||||
<!-- Title bar -->
|
||||
<div style="display:flex; align-items:center; gap:8px; margin-bottom:16px; padding-bottom:12px; border-bottom:1px solid var(--border);">
|
||||
<div style="width:4px; height:20px; background:var(--amber); border-radius:2px; box-shadow:0 0 8px rgba(232,147,10,0.5);"></div>
|
||||
<h2 id="new-file-title" style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:16px; letter-spacing:0.08em; text-transform:uppercase; color:var(--text);">New Personality File</h2>
|
||||
</div>
|
||||
<p class="subtle mb-4">Give your fixture a name to get started. You can change it later.</p>
|
||||
<label class="label" for="new-file-name">Fixture Name</label>
|
||||
<!-- svelte-ignore a11y-autofocus -->
|
||||
<input
|
||||
id="new-file-name"
|
||||
class="input mt-2"
|
||||
style="font-family:'Barlow Condensed',sans-serif; font-size:15px; font-weight:700; letter-spacing:0.04em;"
|
||||
type="text"
|
||||
maxlength={NAME_LEN}
|
||||
placeholder="e.g. MyFixture"
|
||||
bind:value={newFileName}
|
||||
autofocus
|
||||
on:keydown={(e) => { if (e.key === 'Enter') confirmNewFile(); if (e.key === 'Escape') cancelNewFile(); }}
|
||||
/>
|
||||
<div class="mt-5 flex justify-end gap-2">
|
||||
<button class="btn" type="button" on:click={cancelNewFile}>Cancel</button>
|
||||
<button class="btn btn-primary" type="button" on:click={confirmNewFile}>Create File</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showPublishModal}
|
||||
<PublishModal
|
||||
personality={normalized}
|
||||
{displayName}
|
||||
onConfirm={handlePublish}
|
||||
onCancel={() => (showPublishModal = false)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if publishResult}
|
||||
<PublishSuccessModal
|
||||
result={publishResult}
|
||||
onDone={() => (publishResult = null)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if showClearModal}
|
||||
<ClearDataModal
|
||||
fileCount={previousFiles.length}
|
||||
snapshotCount={snapshots.length}
|
||||
onConfirm={handleClearData}
|
||||
onCancel={() => (showClearModal = false)}
|
||||
/>
|
||||
{/if}
|
||||
176
src/routes/about/+page.svelte
Normal file
176
src/routes/about/+page.svelte
Normal file
@@ -0,0 +1,176 @@
|
||||
<svelte:head>
|
||||
<title>About — ETC PRS Editor</title>
|
||||
</svelte:head>
|
||||
|
||||
<!-- Top bar -->
|
||||
<div style="position:sticky; top:0; z-index:100; background:var(--surface);
|
||||
border-bottom:1px solid var(--border); backdrop-filter:blur(12px);">
|
||||
<div style="max-width:1400px; margin:0 auto; padding:0 20px; height:52px;
|
||||
display:flex; align-items:center; gap:16px;">
|
||||
<div style="display:flex; align-items:center; gap:10px;
|
||||
border-right:1px solid var(--border); padding-right:16px; flex-shrink:0;">
|
||||
<div style="width:6px; height:26px; background:var(--amber);
|
||||
box-shadow:0 0 10px rgba(232,147,10,0.6); border-radius:2px;"></div>
|
||||
<div>
|
||||
<div style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:14px;
|
||||
letter-spacing:0.12em; text-transform:uppercase; color:var(--text);">ETC PRS</div>
|
||||
<div style="font-family:'DM Mono',monospace; font-size:9px; letter-spacing:0.1em;
|
||||
color:var(--cyan); opacity:0.8; text-transform:uppercase;">About</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="flex:1;"></div>
|
||||
<a class="btn" href="/" style="text-decoration:none;">← App</a>
|
||||
<a class="btn" href="/library" style="text-decoration:none;">Library</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="max-width:860px; margin:0 auto; padding:40px 20px;">
|
||||
|
||||
<!-- Hero -->
|
||||
<div style="margin-bottom:40px;">
|
||||
<div style="font-family:'DM Mono',monospace; font-size:11px; letter-spacing:0.12em;
|
||||
text-transform:uppercase; color:var(--text3); margin-bottom:10px;">
|
||||
What is this?
|
||||
</div>
|
||||
<h1 style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:40px;
|
||||
letter-spacing:0.04em; text-transform:uppercase; color:var(--text);
|
||||
line-height:1.05; margin-bottom:16px;">
|
||||
ETC PRS<br/>Viewer & Editor
|
||||
</h1>
|
||||
<p style="font-size:16px; color:var(--text2); line-height:1.7; max-width:600px;">
|
||||
A web-based tool for inspecting, editing, and sharing ETC Expression
|
||||
fixture personality files — right in your browser, with no installation required.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<div style="height:1px; background:var(--border); margin-bottom:40px;"></div>
|
||||
|
||||
<!-- Features grid -->
|
||||
<div style="margin-bottom:40px;">
|
||||
<h2 style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:20px;
|
||||
letter-spacing:0.08em; text-transform:uppercase; color:var(--text);
|
||||
margin-bottom:20px;">
|
||||
Features
|
||||
</h2>
|
||||
|
||||
<div style="display:grid; grid-template-columns:repeat(auto-fill, minmax(240px, 1fr)); gap:12px;">
|
||||
{#each features as f}
|
||||
<div class="panel" style="padding:16px 18px;">
|
||||
<div style="font-family:'DM Mono',monospace; font-size:18px; margin-bottom:10px;
|
||||
color:var(--amber);">{f.icon}</div>
|
||||
<div style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:15px;
|
||||
letter-spacing:0.05em; text-transform:uppercase; color:var(--text);
|
||||
margin-bottom:6px;">{f.title}</div>
|
||||
<p style="font-size:13px; color:var(--text2); line-height:1.6;">{f.body}</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<div style="height:1px; background:var(--border); margin-bottom:40px;"></div>
|
||||
|
||||
<!-- 16-bit detail -->
|
||||
<div style="margin-bottom:40px;">
|
||||
<h2 style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:20px;
|
||||
letter-spacing:0.08em; text-transform:uppercase; color:var(--text);
|
||||
margin-bottom:14px;">
|
||||
16-bit Channel Handling
|
||||
</h2>
|
||||
<p style="font-size:14px; color:var(--text2); line-height:1.8; margin-bottom:12px;">
|
||||
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 <code style="font-family:'DM Mono',monospace; font-size:12px; color:var(--amber);
|
||||
background:var(--raised); padding:1px 5px; border-radius:2px;">0x04</code> flag bit,
|
||||
and the following channel is treated as its pair.
|
||||
</p>
|
||||
<p style="font-size:14px; color:var(--text2); line-height:1.8;">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<div style="height:1px; background:var(--border); margin-bottom:40px;"></div>
|
||||
|
||||
<!-- Data & privacy -->
|
||||
<div style="margin-bottom:40px;">
|
||||
<h2 style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:20px;
|
||||
letter-spacing:0.08em; text-transform:uppercase; color:var(--text);
|
||||
margin-bottom:14px;">
|
||||
Privacy & Data
|
||||
</h2>
|
||||
<div style="display:grid; grid-template-columns:repeat(auto-fill, minmax(220px, 1fr)); gap:10px;">
|
||||
{#each privacyPoints as point}
|
||||
<div style="display:flex; gap:10px; align-items:flex-start; padding:12px 14px;
|
||||
border-radius:3px; border:1px solid var(--border); background:var(--raised);">
|
||||
<span style="color:var(--green); flex-shrink:0; font-size:14px;">✓</span>
|
||||
<span style="font-size:13px; color:var(--text2); line-height:1.5;">{point}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Disclosures link -->
|
||||
<div style="padding:16px 20px; border-radius:3px; border:1px solid rgba(232,147,10,0.2);
|
||||
background:var(--amber-dim); display:flex; align-items:center;
|
||||
justify-content:space-between; gap:12px; flex-wrap:wrap;">
|
||||
<div>
|
||||
<div style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:14px;
|
||||
letter-spacing:0.06em; text-transform:uppercase; color:var(--amber);
|
||||
margin-bottom:3px;">Ethical Disclosures</div>
|
||||
<div style="font-size:13px; color:var(--text2);">
|
||||
AI usage, reverse engineering methodology, and affiliation statement.
|
||||
</div>
|
||||
</div>
|
||||
<a class="btn" href="/disclosures" style="text-decoration:none; flex-shrink:0;">
|
||||
Read disclosures →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const features = [
|
||||
{
|
||||
icon: '◈',
|
||||
title: 'Open & View',
|
||||
body: 'Load any .prs file directly from your machine. Card or table layout, accurate parsing from the binary structure.'
|
||||
},
|
||||
{
|
||||
icon: '✎',
|
||||
title: 'Edit Personalities',
|
||||
body: 'Modify attributes, flags, and home values. Supports Independent, LTP, 16-bit, and Flipped channel modes.'
|
||||
},
|
||||
{
|
||||
icon: '⇅',
|
||||
title: 'Drag & Drop Reorder',
|
||||
body: '16-bit pairs move as a single unit. Drag handles prevent accidental reorders while editing inputs.'
|
||||
},
|
||||
{
|
||||
icon: '⬡',
|
||||
title: 'Export PRS',
|
||||
body: 'Export binary-compatible .prs files from the viewer or editor. Output matches ETC Personality Editor structure.'
|
||||
},
|
||||
{
|
||||
icon: '◎',
|
||||
title: 'Snapshots',
|
||||
body: 'Save named checkpoints while editing and restore them at any time. Stored locally in your browser.'
|
||||
},
|
||||
{
|
||||
icon: '⊕',
|
||||
title: 'Public Library',
|
||||
body: 'Browse and share personalities with the community. Search by fixture name, manufacturer, or tags.'
|
||||
},
|
||||
];
|
||||
|
||||
const privacyPoints = [
|
||||
'All file handling is local to your machine',
|
||||
'No data is transmitted externally when using the editor',
|
||||
'Published personalities are shared only when you explicitly choose to publish',
|
||||
'Local file history and snapshots live in your browser\'s localStorage',
|
||||
'No tracking, analytics, or advertising',
|
||||
];
|
||||
</script>
|
||||
18
src/routes/admin/+layout.server.js
Normal file
18
src/routes/admin/+layout.server.js
Normal file
@@ -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 } };
|
||||
}
|
||||
43
src/routes/admin/+layout.svelte
Normal file
43
src/routes/admin/+layout.svelte
Normal file
@@ -0,0 +1,43 @@
|
||||
<script>
|
||||
export let data;
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Admin — ETC PRS</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if data.admin}
|
||||
<!-- Admin top bar -->
|
||||
<div style="position:sticky; top:0; z-index:100; background:var(--surface);
|
||||
border-bottom:1px solid var(--border); backdrop-filter:blur(12px);">
|
||||
<div style="max-width:1400px; margin:0 auto; padding:0 20px; height:52px;
|
||||
display:flex; align-items:center; gap:16px;">
|
||||
<div style="display:flex; align-items:center; gap:10px;
|
||||
border-right:1px solid var(--border); padding-right:16px; flex-shrink:0;">
|
||||
<div style="width:6px; height:26px; background:var(--red);
|
||||
box-shadow:0 0 10px rgba(248,113,113,0.5); border-radius:2px;"></div>
|
||||
<div>
|
||||
<div style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:14px;
|
||||
letter-spacing:0.12em; text-transform:uppercase; color:var(--text);">ETC PRS</div>
|
||||
<div style="font-family:'DM Mono',monospace; font-size:9px; letter-spacing:0.1em;
|
||||
color:var(--red); opacity:0.8; text-transform:uppercase;">Admin Console</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="flex:1;"></div>
|
||||
<div style="font-family:'DM Mono',monospace; font-size:11px; color:var(--text3);">
|
||||
{data.admin.username}
|
||||
</div>
|
||||
<a class="btn" href="/" style="text-decoration:none;">← App</a>
|
||||
<form method="POST" action="/admin/logout">
|
||||
<button class="btn btn-danger" type="submit" style="padding:5px 12px;">Logout</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="max-width:1400px; margin:0 auto; padding:24px 20px;">
|
||||
<slot />
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Login page — no chrome -->
|
||||
<slot />
|
||||
{/if}
|
||||
32
src/routes/admin/+page.server.js
Normal file
32
src/routes/admin/+page.server.js
Normal file
@@ -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)
|
||||
};
|
||||
}
|
||||
647
src/routes/admin/+page.svelte
Normal file
647
src/routes/admin/+page.svelte
Normal file
@@ -0,0 +1,647 @@
|
||||
<script>
|
||||
import { goto, invalidateAll } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { makeSlug } from '$lib/shared/slugify.js';
|
||||
|
||||
export let data;
|
||||
|
||||
let deleting = {};
|
||||
let dismissing = {};
|
||||
let markingRead = {};
|
||||
let adminSearch = data.adminQ;
|
||||
let searchTimer;
|
||||
|
||||
// Edit state
|
||||
let editingId = null; // which personality is open for editing
|
||||
let editForm = {}; // form field values
|
||||
let editSaving = false;
|
||||
let editError = '';
|
||||
|
||||
// Replace binary state
|
||||
let replaceId = null;
|
||||
let replacePreview = null; // { current, incoming }
|
||||
let replaceBytes = null;
|
||||
let replaceSaving = false;
|
||||
let replaceError = '';
|
||||
let replaceInput; // file input ref
|
||||
|
||||
function startEdit(p) {
|
||||
editingId = p.id;
|
||||
editError = '';
|
||||
editForm = {
|
||||
name: p.name,
|
||||
prs_name: p.prs_name ?? '',
|
||||
manufacturer: p.manufacturer ?? '',
|
||||
notes: p.notes ?? '',
|
||||
tags: JSON.parse(p.tags ?? '[]').join(', '),
|
||||
creator_handle: p.creator_handle ?? ''
|
||||
};
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editingId = null;
|
||||
editError = '';
|
||||
replacePreview = null;
|
||||
replaceBytes = null;
|
||||
replaceError = '';
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
editSaving = true;
|
||||
editError = '';
|
||||
try {
|
||||
const res = await fetch('/api/admin/edit-personality', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
id: editingId,
|
||||
name: editForm.name,
|
||||
prs_name: editForm.prs_name,
|
||||
manufacturer: editForm.manufacturer,
|
||||
notes: editForm.notes,
|
||||
tags: editForm.tags.split(',').map(t => t.trim()).filter(Boolean),
|
||||
creator_handle: editForm.creator_handle
|
||||
})
|
||||
});
|
||||
if (!res.ok) {
|
||||
const b = await res.json().catch(() => ({}));
|
||||
throw new Error(b.message ?? `Error ${res.status}`);
|
||||
}
|
||||
await invalidateAll();
|
||||
cancelEdit();
|
||||
} catch (err) {
|
||||
editError = err.message;
|
||||
} finally {
|
||||
editSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReplaceFile(event) {
|
||||
const file = event.currentTarget.files?.[0];
|
||||
if (!file) return;
|
||||
replaceError = '';
|
||||
replacePreview = null;
|
||||
replaceBytes = null;
|
||||
event.currentTarget.value = '';
|
||||
|
||||
try {
|
||||
const buf = await file.arrayBuffer();
|
||||
const data = Array.from(new Uint8Array(buf));
|
||||
|
||||
// Preview first
|
||||
const res = await fetch('/api/admin/replace-binary', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: replaceId, data, preview: true })
|
||||
});
|
||||
if (!res.ok) {
|
||||
const b = await res.json().catch(() => ({}));
|
||||
throw new Error(b.message ?? `Error ${res.status}`);
|
||||
}
|
||||
const result = await res.json();
|
||||
replacePreview = result;
|
||||
replaceBytes = data;
|
||||
} catch (err) {
|
||||
replaceError = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmReplace() {
|
||||
replaceSaving = true;
|
||||
replaceError = '';
|
||||
try {
|
||||
const res = await fetch('/api/admin/replace-binary', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: replaceId, data: replaceBytes, preview: false })
|
||||
});
|
||||
if (!res.ok) {
|
||||
const b = await res.json().catch(() => ({}));
|
||||
throw new Error(b.message ?? `Error ${res.status}`);
|
||||
}
|
||||
await invalidateAll();
|
||||
replacePreview = null;
|
||||
replaceBytes = null;
|
||||
replaceId = null;
|
||||
} catch (err) {
|
||||
replaceError = err.message;
|
||||
} finally {
|
||||
replaceSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function navigate(params) {
|
||||
const u = new URL($page.url);
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v) u.searchParams.set(k, v);
|
||||
else u.searchParams.delete(k);
|
||||
}
|
||||
goto(u.toString(), { keepFocus: true });
|
||||
}
|
||||
|
||||
function onAdminSearch() {
|
||||
clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(() => navigate({ q: adminSearch, page: '1' }), 350);
|
||||
}
|
||||
|
||||
async function markRead(id, all = false) {
|
||||
const key = all ? '__all__' : id;
|
||||
markingRead[key] = true;
|
||||
await fetch('/api/admin/mark-read', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(all ? { all: true } : { id })
|
||||
});
|
||||
await invalidateAll();
|
||||
markingRead[key] = false;
|
||||
}
|
||||
|
||||
async function dismissReport(id) {
|
||||
dismissing[id] = true;
|
||||
await fetch('/api/admin/dismiss-report', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id })
|
||||
});
|
||||
await invalidateAll();
|
||||
dismissing[id] = false;
|
||||
}
|
||||
|
||||
async function deletePersonality(id, fromReport = false) {
|
||||
const key = fromReport ? `r_${id}` : id;
|
||||
if (!confirm('Soft-delete this personality? It will be removed from the library and hard-deleted after 60 days.')) return;
|
||||
deleting[key] = true;
|
||||
await fetch('/api/admin/delete-personality', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id })
|
||||
});
|
||||
await invalidateAll();
|
||||
deleting[key] = false;
|
||||
}
|
||||
|
||||
function formatDate(iso) {
|
||||
return new Date(iso).toLocaleDateString(undefined, { month:'short', day:'numeric', year:'numeric' });
|
||||
}
|
||||
|
||||
function formatTime(iso) {
|
||||
return new Date(iso).toLocaleString();
|
||||
}
|
||||
|
||||
const reasonLabels = {
|
||||
'incorrect-data': 'Incorrect data',
|
||||
'duplicate': 'Duplicate',
|
||||
'inappropriate': 'Inappropriate',
|
||||
'spam': 'Spam / test',
|
||||
'other': 'Other'
|
||||
};
|
||||
</script>
|
||||
|
||||
<!-- Stats strip -->
|
||||
<div style="display:grid; grid-template-columns:repeat(5,1fr); gap:1px;
|
||||
background:var(--border); border-radius:4px; overflow:hidden;
|
||||
margin-bottom:24px; border:1px solid var(--border);">
|
||||
{#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}
|
||||
<div style="background:var(--surface); padding:16px 20px;">
|
||||
<div class="label" style="margin-bottom:6px;">{stat.label}</div>
|
||||
<div style="font-family:'DM Mono',monospace; font-size:28px; font-weight:500;
|
||||
color:{stat.color}; text-shadow:0 0 12px {stat.color}40; line-height:1;">
|
||||
{stat.value}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Messages section -->
|
||||
<div class="panel" style="margin-bottom:24px; overflow:hidden;">
|
||||
<div style="padding:14px 18px; border-bottom:1px solid var(--border);
|
||||
display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap;">
|
||||
<div style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:15px;
|
||||
letter-spacing:0.06em; text-transform:uppercase; color:var(--text);">
|
||||
Messages
|
||||
{#if data.stats.unreadMessages > 0}
|
||||
<span style="margin-left:8px; padding:2px 8px; border-radius:3px;
|
||||
background:var(--cyan-dim); border:1px solid rgba(45,212,200,0.3);
|
||||
color:var(--cyan); font-size:11px;">{data.stats.unreadMessages} unread</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div style="display:flex; gap:6px; flex-wrap:wrap; align-items:center;">
|
||||
{#each [['unread','Unread'],['all','All']] as [val, label]}
|
||||
<button class="btn" style="padding:4px 10px; font-size:11px;
|
||||
{data.messageFilter === val ? 'background:var(--cyan-dim); border-color:var(--cyan); color:var(--cyan);' : ''}"
|
||||
type="button" on:click={() => navigate({ messages: val })}>
|
||||
{label}
|
||||
</button>
|
||||
{/each}
|
||||
{#if data.stats.unreadMessages > 0}
|
||||
<button class="btn" style="padding:4px 10px; font-size:11px;" type="button"
|
||||
disabled={!!markingRead['__all__']}
|
||||
on:click={() => markRead(null, true)}>
|
||||
{markingRead['__all__'] ? '…' : 'Mark all read'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if data.messages.length === 0}
|
||||
<div style="padding:32px; text-align:center; font-family:'DM Mono',monospace;
|
||||
font-size:12px; color:var(--text3);">
|
||||
No messages in this view.
|
||||
</div>
|
||||
{:else}
|
||||
{#each data.messages as msg}
|
||||
<div style="padding:14px 18px; border-bottom:1px solid var(--border);
|
||||
{msg.read ? 'opacity:0.55;' : ''}">
|
||||
<div style="display:flex; align-items:flex-start; justify-content:space-between;
|
||||
gap:12px; flex-wrap:wrap;">
|
||||
<div style="min-width:0; flex:1;">
|
||||
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin-bottom:4px;">
|
||||
{#if !msg.read}
|
||||
<span style="width:6px; height:6px; border-radius:50%;
|
||||
background:var(--cyan); flex-shrink:0;
|
||||
box-shadow:0 0 6px rgba(45,212,200,0.6);"></span>
|
||||
{/if}
|
||||
<span style="font-family:'Barlow Condensed',sans-serif; font-weight:700;
|
||||
font-size:15px; letter-spacing:0.03em; color:var(--text);">
|
||||
{msg.subject}
|
||||
</span>
|
||||
<span style="font-family:'Barlow Condensed',sans-serif; font-size:12px;
|
||||
color:var(--amber); font-weight:600;">
|
||||
{msg.name || 'Anonymous'}
|
||||
</span>
|
||||
{#if msg.email}
|
||||
<a href="mailto:{msg.email}"
|
||||
style="font-family:'DM Mono',monospace; font-size:11px; color:var(--cyan);
|
||||
text-decoration:none;">
|
||||
{msg.email}
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
<div style="font-size:13px; color:var(--text2); line-height:1.6;
|
||||
margin-bottom:6px; white-space:pre-wrap;">
|
||||
{msg.message}
|
||||
</div>
|
||||
<div style="font-family:'DM Mono',monospace; font-size:10px; color:var(--text3);">
|
||||
{formatTime(msg.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
{#if !msg.read}
|
||||
<button class="btn" style="padding:5px 10px; font-size:12px; flex-shrink:0;"
|
||||
type="button"
|
||||
disabled={!!markingRead[msg.id]}
|
||||
on:click={() => markRead(msg.id)}>
|
||||
{markingRead[msg.id] ? '…' : 'Mark read'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Reports section -->
|
||||
<div class="panel" style="margin-bottom:24px; overflow:hidden;">
|
||||
<div style="padding:14px 18px; border-bottom:1px solid var(--border);
|
||||
display:flex; align-items:center; justify-content:space-between; gap:12px;">
|
||||
<div style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:15px;
|
||||
letter-spacing:0.06em; text-transform:uppercase; color:var(--text);">
|
||||
Reports
|
||||
{#if data.stats.openReports > 0}
|
||||
<span style="margin-left:8px; padding:2px 8px; border-radius:3px;
|
||||
background:var(--red-dim); border:1px solid rgba(248,113,113,0.3);
|
||||
color:var(--red); font-size:11px;">{data.stats.openReports} open</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div style="display:flex; gap:4px;">
|
||||
{#each [['open','Open'],['dismissed','Dismissed'],['all','All']] as [val, label]}
|
||||
<button class="btn" style="padding:4px 10px; font-size:11px;
|
||||
{data.reportFilter === val ? 'background:var(--amber-dim); border-color:var(--amber); color:var(--amber);' : ''}"
|
||||
type="button" on:click={() => navigate({ reports: val })}>
|
||||
{label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if data.reports.length === 0}
|
||||
<div style="padding:32px; text-align:center; font-family:'DM Mono',monospace;
|
||||
font-size:12px; color:var(--text3);">
|
||||
No reports in this view.
|
||||
</div>
|
||||
{:else}
|
||||
{#each data.reports as report}
|
||||
<div style="padding:14px 18px; border-bottom:1px solid var(--border);
|
||||
{report.resolved > 0 ? 'opacity:0.5;' : ''}">
|
||||
<div style="display:flex; align-items:flex-start; justify-content:space-between; gap:12px; flex-wrap:wrap;">
|
||||
<div style="min-width:0;">
|
||||
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin-bottom:4px;">
|
||||
<a href="/p/{report.personality_id}/{makeSlug(report.personality_name ?? '')}"
|
||||
target="_blank"
|
||||
style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:15px;
|
||||
letter-spacing:0.03em; color:var(--text); text-decoration:none;">
|
||||
{report.personality_name ?? report.personality_id}
|
||||
</a>
|
||||
{#if report.manufacturer}
|
||||
<span style="font-size:12px; color:var(--text3);">{report.manufacturer}</span>
|
||||
{/if}
|
||||
{#if report.deleted_at}
|
||||
<span style="font-family:'Barlow Condensed',sans-serif; font-size:10px; font-weight:700;
|
||||
padding:1px 5px; border-radius:2px; letter-spacing:0.08em;
|
||||
background:var(--red-dim); border:1px solid rgba(248,113,113,0.3); color:var(--red);">
|
||||
DELETED
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div style="font-family:'DM Mono',monospace; font-size:11px; color:var(--amber); margin-bottom:3px;">
|
||||
{reasonLabels[report.reason] ?? report.reason}
|
||||
</div>
|
||||
{#if report.notes}
|
||||
<div style="font-size:12px; color:var(--text2); margin-bottom:3px;">"{report.notes}"</div>
|
||||
{/if}
|
||||
<div style="font-family:'DM Mono',monospace; font-size:10px; color:var(--text3);">
|
||||
Reported {formatTime(report.created_at)}
|
||||
{#if report.resolved === 1} · Dismissed
|
||||
{:else if report.resolved === 2} · Removed
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if report.resolved === 0}
|
||||
<div style="display:flex; gap:6px; flex-shrink:0;">
|
||||
<button class="btn" style="padding:5px 10px; font-size:12px;" type="button"
|
||||
disabled={!!dismissing[report.id]}
|
||||
on:click={() => dismissReport(report.id)}>
|
||||
{dismissing[report.id] ? '…' : 'Dismiss'}
|
||||
</button>
|
||||
{#if !report.deleted_at}
|
||||
<button class="btn btn-danger" style="padding:5px 10px; font-size:12px;" type="button"
|
||||
disabled={!!deleting[`r_${report.personality_id}`]}
|
||||
on:click={() => deletePersonality(report.personality_id, true)}>
|
||||
{deleting[`r_${report.personality_id}`] ? '…' : 'Soft Delete'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Personalities section -->
|
||||
<div class="panel" style="overflow:hidden;">
|
||||
<div style="padding:14px 18px; border-bottom:1px solid var(--border);
|
||||
display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap;">
|
||||
<div style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:15px;
|
||||
letter-spacing:0.06em; text-transform:uppercase; color:var(--text);">
|
||||
All Personalities
|
||||
<span style="font-weight:400; font-size:12px; color:var(--text3); margin-left:8px;">
|
||||
{data.totalPersonalities} total
|
||||
</span>
|
||||
</div>
|
||||
<input class="input" style="max-width:260px;"
|
||||
type="search" placeholder="Search…"
|
||||
bind:value={adminSearch}
|
||||
on:input={onAdminSearch} />
|
||||
</div>
|
||||
|
||||
<div style="overflow-x:auto;">
|
||||
<table style="width:100%; border-collapse:collapse;">
|
||||
<thead>
|
||||
<tr style="background:var(--raised);">
|
||||
{#each ['Fixture','Manufacturer','Ch','By','Published','Status',''] as h}
|
||||
<th style="padding:9px 14px; text-align:left; font-family:'Barlow Condensed',sans-serif;
|
||||
font-size:11px; font-weight:700; letter-spacing:0.12em; text-transform:uppercase;
|
||||
color:var(--text2); border-bottom:1px solid var(--border); white-space:nowrap;">
|
||||
{h}
|
||||
</th>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each data.personalities as p}
|
||||
<!-- Data row -->
|
||||
<tr style="border-bottom:{editingId === p.id ? 'none' : '1px solid var(--border)'};
|
||||
{p.deleted_at ? 'opacity:0.45;' : ''}
|
||||
background:{editingId === p.id ? 'var(--raised)' : ''};
|
||||
transition:background 0.1s;"
|
||||
on:mouseenter={(e) => { if (editingId !== p.id) e.currentTarget.style.background='var(--raised)'; }}
|
||||
on:mouseleave={(e) => { if (editingId !== p.id) e.currentTarget.style.background=''; }}>
|
||||
<td style="padding:9px 14px;">
|
||||
<a href="/p/{p.id}/{makeSlug(p.name)}" target="_blank"
|
||||
style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:14px;
|
||||
letter-spacing:0.03em; color:var(--text); text-decoration:none;">
|
||||
{p.name}
|
||||
</a>
|
||||
{#if p.prs_name && p.prs_name !== p.name}
|
||||
<div style="font-family:'DM Mono',monospace; font-size:10px; color:var(--amber);">PRS: {p.prs_name}</div>
|
||||
{/if}
|
||||
</td>
|
||||
<td style="padding:9px 14px; font-size:12px; color:var(--text2);">{p.manufacturer || '—'}</td>
|
||||
<td style="padding:9px 14px; font-family:'DM Mono',monospace; font-size:13px; color:var(--amber);">{p.channel_count}</td>
|
||||
<td style="padding:9px 14px; font-size:12px; color:var(--text3);">{p.creator_handle || '—'}</td>
|
||||
<td style="padding:9px 14px; font-family:'DM Mono',monospace; font-size:11px; color:var(--text3); white-space:nowrap;">{formatDate(p.created_at)}</td>
|
||||
<td style="padding:9px 14px;">
|
||||
{#if p.deleted_at}
|
||||
<span style="font-family:'Barlow Condensed',sans-serif; font-size:10px; font-weight:700;
|
||||
padding:1px 6px; border-radius:2px; letter-spacing:0.08em;
|
||||
background:var(--red-dim); border:1px solid rgba(248,113,113,0.3); color:var(--red);">
|
||||
Deleted {formatDate(p.deleted_at)}
|
||||
</span>
|
||||
{:else}
|
||||
<span style="font-family:'Barlow Condensed',sans-serif; font-size:10px; font-weight:700;
|
||||
padding:1px 6px; border-radius:2px; letter-spacing:0.08em;
|
||||
background:var(--green-dim); border:1px solid rgba(74,222,128,0.2); color:var(--green);">
|
||||
Live
|
||||
</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td style="padding:9px 10px; white-space:nowrap;">
|
||||
<div style="display:flex; gap:4px;">
|
||||
{#if !p.deleted_at}
|
||||
{#if editingId === p.id}
|
||||
<button class="btn" style="padding:4px 8px; font-size:11px; color:var(--text3);"
|
||||
type="button" on:click={cancelEdit}>Cancel</button>
|
||||
{:else}
|
||||
<button class="btn" style="padding:4px 8px; font-size:11px;"
|
||||
type="button" on:click={() => startEdit(p)}>Edit</button>
|
||||
<button class="btn btn-danger" style="padding:4px 8px; font-size:11px;" type="button"
|
||||
disabled={!!deleting[p.id]}
|
||||
on:click={() => deletePersonality(p.id)}>
|
||||
{deleting[p.id] ? '…' : 'Delete'}
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Inline edit panel -->
|
||||
{#if editingId === p.id}
|
||||
<tr style="border-bottom:1px solid var(--border);">
|
||||
<td colspan="7" style="padding:0; background:var(--bg);">
|
||||
<div style="padding:16px 18px; border-top:1px solid var(--border2);">
|
||||
<div style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:12px;
|
||||
letter-spacing:0.1em; text-transform:uppercase; color:var(--amber);
|
||||
margin-bottom:14px;">
|
||||
Editing: {p.name}
|
||||
</div>
|
||||
|
||||
<!-- Metadata fields -->
|
||||
<div style="display:grid; grid-template-columns:1fr 1fr; gap:12px; margin-bottom:12px;">
|
||||
<div>
|
||||
<label class="label" style="display:block; margin-bottom:5px;">Library Name</label>
|
||||
<input class="input" type="text" maxlength="120" bind:value={editForm.name} />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label" style="display:block; margin-bottom:5px;">
|
||||
PRS Name
|
||||
<span style="font-family:'DM Mono',monospace; font-size:10px; font-weight:400;
|
||||
text-transform:none; color:var(--text3); margin-left:4px;">(12 char max)</span>
|
||||
</label>
|
||||
<input class="input" style="font-family:'DM Mono',monospace;"
|
||||
type="text" maxlength="12" bind:value={editForm.prs_name} />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label" style="display:block; margin-bottom:5px;">Manufacturer</label>
|
||||
<input class="input" type="text" maxlength="128" bind:value={editForm.manufacturer} />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label" style="display:block; margin-bottom:5px;">Creator Handle</label>
|
||||
<input class="input" type="text" maxlength="64" bind:value={editForm.creator_handle} />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label" style="display:block; margin-bottom:5px;">
|
||||
Tags
|
||||
<span style="font-family:'DM Mono',monospace; font-size:10px; font-weight:400;
|
||||
text-transform:none; color:var(--text3); margin-left:4px;">(comma separated)</span>
|
||||
</label>
|
||||
<input class="input" type="text" bind:value={editForm.tags}
|
||||
placeholder="e.g. verified, 16-bit, spot" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label" style="display:block; margin-bottom:5px;">Notes</label>
|
||||
<input class="input" type="text" maxlength="1000" bind:value={editForm.notes} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Metadata actions -->
|
||||
<div style="display:flex; align-items:center; gap:8px; margin-bottom:16px;">
|
||||
<button class="btn btn-primary" style="padding:6px 14px;" type="button"
|
||||
disabled={editSaving} on:click={saveEdit}>
|
||||
{editSaving ? 'Saving…' : 'Save Changes'}
|
||||
</button>
|
||||
<button class="btn" style="padding:6px 14px;" type="button" on:click={cancelEdit}>Cancel</button>
|
||||
{#if editError}
|
||||
<span style="font-size:12px; color:var(--red);">{editError}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Replace binary section -->
|
||||
<div style="border-top:1px solid var(--border); padding-top:14px;">
|
||||
<div style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:12px;
|
||||
letter-spacing:0.1em; text-transform:uppercase; color:var(--text2);
|
||||
margin-bottom:10px;">Replace Binary (.prs file)</div>
|
||||
|
||||
{#if replaceId !== p.id || !replacePreview}
|
||||
<div style="display:flex; align-items:center; gap:8px;">
|
||||
<button class="btn" style="padding:6px 12px; font-size:12px;" type="button"
|
||||
on:click={() => { replaceId = p.id; replacePreview = null; replaceBytes = null; replaceError = ''; replaceInput?.click(); }}>
|
||||
Choose .prs file…
|
||||
</button>
|
||||
<span style="font-size:12px; color:var(--text3);">
|
||||
Current: {p.channel_count}ch · PRS name: {p.prs_name || '(none)'}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Diff preview -->
|
||||
{#if replaceId === p.id && replacePreview}
|
||||
<div style="padding:12px 14px; border-radius:3px; border:1px solid var(--border2);
|
||||
background:var(--raised); margin-bottom:10px;">
|
||||
<div style="font-family:'Barlow Condensed',sans-serif; font-size:12px; font-weight:700;
|
||||
letter-spacing:0.08em; text-transform:uppercase; color:var(--text2); margin-bottom:8px;">
|
||||
Change Summary
|
||||
</div>
|
||||
<div style="display:grid; grid-template-columns:auto 1fr 1fr; gap:6px 16px; font-size:12px; align-items:center;">
|
||||
<span style="color:var(--text3); font-family:'Barlow Condensed',sans-serif; font-size:11px; letter-spacing:0.06em; text-transform:uppercase;">Field</span>
|
||||
<span style="color:var(--text3); font-family:'Barlow Condensed',sans-serif; font-size:11px; letter-spacing:0.06em; text-transform:uppercase;">Current</span>
|
||||
<span style="color:var(--text3); font-family:'Barlow Condensed',sans-serif; font-size:11px; letter-spacing:0.06em; text-transform:uppercase;">Incoming</span>
|
||||
|
||||
<span style="color:var(--text2);">PRS Name</span>
|
||||
<span style="font-family:'DM Mono',monospace; color:var(--text);">{replacePreview.current.prs_name || '—'}</span>
|
||||
<span style="font-family:'DM Mono',monospace;
|
||||
color:{replacePreview.incoming.prs_name !== replacePreview.current.prs_name ? 'var(--amber)' : 'var(--text)'};">
|
||||
{replacePreview.incoming.prs_name || '—'}
|
||||
{#if replacePreview.incoming.prs_name !== replacePreview.current.prs_name}
|
||||
<span style="color:var(--amber); margin-left:4px;">← changed</span>
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
<span style="color:var(--text2);">Channels</span>
|
||||
<span style="font-family:'DM Mono',monospace; color:var(--text);">{replacePreview.current.channel_count}</span>
|
||||
<span style="font-family:'DM Mono',monospace;
|
||||
color:{replacePreview.incoming.channel_count !== replacePreview.current.channel_count ? 'var(--amber)' : 'var(--text)'};">
|
||||
{replacePreview.incoming.channel_count}
|
||||
{#if replacePreview.incoming.channel_count !== replacePreview.current.channel_count}
|
||||
<span style="color:var(--amber); margin-left:4px;">← changed</span>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex; align-items:center; gap:8px;">
|
||||
<button class="btn btn-primary" style="padding:6px 14px; font-size:12px;" type="button"
|
||||
disabled={replaceSaving} on:click={confirmReplace}>
|
||||
{replaceSaving ? 'Replacing…' : 'Confirm Replace'}
|
||||
</button>
|
||||
<button class="btn" style="padding:6px 12px; font-size:12px;" type="button"
|
||||
on:click={() => { replacePreview = null; replaceBytes = null; replaceId = null; }}>
|
||||
Cancel
|
||||
</button>
|
||||
<button class="btn" style="padding:6px 12px; font-size:12px;" type="button"
|
||||
on:click={() => { replacePreview = null; replaceBytes = null; replaceInput?.click(); }}>
|
||||
Choose different file
|
||||
</button>
|
||||
{#if replaceError}
|
||||
<span style="font-size:12px; color:var(--red);">{replaceError}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if replaceError && !replacePreview}
|
||||
<div style="margin-top:8px; font-size:12px; color:var(--red);">{replaceError}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Hidden file input for binary replacement -->
|
||||
<input bind:this={replaceInput} type="file" accept=".prs,application/octet-stream"
|
||||
style="display:none;" on:change={handleReplaceFile} />
|
||||
|
||||
<!-- Pagination -->
|
||||
{#if data.totalPages > 1}
|
||||
<div style="padding:12px 18px; border-top:1px solid var(--border);
|
||||
display:flex; align-items:center; gap:6px; justify-content:flex-end;">
|
||||
{#each Array.from({length: data.totalPages}, (_, i) => i+1) as p}
|
||||
<button class="btn" style="padding:4px 10px; font-family:'DM Mono',monospace; font-size:12px;
|
||||
{p === data.adminPage ? 'background:var(--amber-dim); border-color:var(--amber); color:var(--amber);' : ''}"
|
||||
type="button" on:click={() => navigate({ page: String(p) })}>
|
||||
{p}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
49
src/routes/admin/login/+page.server.js
Normal file
49
src/routes/admin/login/+page.server.js
Normal file
@@ -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);
|
||||
}
|
||||
};
|
||||
60
src/routes/admin/login/+page.svelte
Normal file
60
src/routes/admin/login/+page.svelte
Normal file
@@ -0,0 +1,60 @@
|
||||
<script>
|
||||
import { enhance } from '$app/forms';
|
||||
export let form;
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Admin Login — ETC PRS</title></svelte:head>
|
||||
|
||||
<div style="min-height:100vh; display:flex; align-items:center; justify-content:center; padding:20px;">
|
||||
<div class="panel" style="width:100%; max-width:380px; padding:28px;
|
||||
box-shadow:0 0 0 1px rgba(248,113,113,0.15), 0 24px 64px rgba(0,0,0,0.8);">
|
||||
|
||||
<!-- Title -->
|
||||
<div style="display:flex; align-items:center; gap:8px; margin-bottom:24px;
|
||||
padding-bottom:14px; border-bottom:1px solid var(--border);">
|
||||
<div style="width:4px; height:20px; background:var(--red); border-radius:2px;
|
||||
box-shadow:0 0 8px rgba(248,113,113,0.5);"></div>
|
||||
<div>
|
||||
<div style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:16px;
|
||||
letter-spacing:0.08em; text-transform:uppercase; color:var(--text);">
|
||||
ETC PRS Admin
|
||||
</div>
|
||||
<div style="font-family:'DM Mono',monospace; font-size:9px; color:var(--text3);
|
||||
text-transform:uppercase; letter-spacing:0.1em; margin-top:1px;">
|
||||
Sign in to continue
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if form?.error}
|
||||
<div style="margin-bottom:16px; padding:10px 12px; border-radius:3px;
|
||||
border:1px solid rgba(248,113,113,0.3); background:var(--red-dim);
|
||||
color:var(--red); font-size:13px;">
|
||||
{form.error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form method="POST" use:enhance>
|
||||
<div style="margin-bottom:14px;">
|
||||
<label class="label" for="username" style="display:block; margin-bottom:6px;">Username</label>
|
||||
<input
|
||||
id="username" name="username" type="text"
|
||||
class="input" autocomplete="username"
|
||||
value={form?.username ?? ''}
|
||||
required autofocus
|
||||
/>
|
||||
</div>
|
||||
<div style="margin-bottom:20px;">
|
||||
<label class="label" for="password" style="display:block; margin-bottom:6px;">Password</label>
|
||||
<input
|
||||
id="password" name="password" type="password"
|
||||
class="input" autocomplete="current-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button class="btn btn-primary" type="submit" style="width:100%; justify-content:center;">
|
||||
Sign In
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
11
src/routes/admin/logout/+page.server.js
Normal file
11
src/routes/admin/logout/+page.server.js
Normal file
@@ -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');
|
||||
}
|
||||
};
|
||||
1
src/routes/admin/logout/+page.svelte
Normal file
1
src/routes/admin/logout/+page.svelte
Normal file
@@ -0,0 +1 @@
|
||||
<!-- Intentionally empty — logout is handled via form POST action -->
|
||||
18
src/routes/api/admin/delete-personality/+server.js
Normal file
18
src/routes/api/admin/delete-personality/+server.js
Normal file
@@ -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 });
|
||||
}
|
||||
14
src/routes/api/admin/dismiss-report/+server.js
Normal file
14
src/routes/api/admin/dismiss-report/+server.js
Normal file
@@ -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 });
|
||||
}
|
||||
32
src/routes/api/admin/edit-personality/+server.js
Normal file
32
src/routes/api/admin/edit-personality/+server.js
Normal file
@@ -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 });
|
||||
}
|
||||
20
src/routes/api/admin/mark-read/+server.js
Normal file
20
src/routes/api/admin/mark-read/+server.js
Normal file
@@ -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 });
|
||||
}
|
||||
13
src/routes/api/admin/messages/+server.js
Normal file
13
src/routes/api/admin/messages/+server.js
Normal file
@@ -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 });
|
||||
}
|
||||
61
src/routes/api/admin/replace-binary/+server.js
Normal file
61
src/routes/api/admin/replace-binary/+server.js
Normal file
@@ -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 });
|
||||
}
|
||||
15
src/routes/api/admin/report-count/+server.js
Normal file
15
src/routes/api/admin/report-count/+server.js
Normal file
@@ -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 });
|
||||
}
|
||||
55
src/routes/api/contact/+server.js
Normal file
55
src/routes/api/contact/+server.js
Normal file
@@ -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 });
|
||||
}
|
||||
38
src/routes/api/delete/+server.js
Normal file
38
src/routes/api/delete/+server.js
Normal file
@@ -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 });
|
||||
}
|
||||
37
src/routes/api/library/+server.js
Normal file
37
src/routes/api/library/+server.js
Normal file
@@ -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; }
|
||||
}
|
||||
14
src/routes/api/manufacturers/+server.js
Normal file
14
src/routes/api/manufacturers/+server.js
Normal file
@@ -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 });
|
||||
}
|
||||
25
src/routes/api/personality/[id]/+server.js
Normal file
25
src/routes/api/personality/[id]/+server.js
Normal file
@@ -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; }
|
||||
}
|
||||
30
src/routes/api/personality/[id]/download/+server.js
Normal file
30
src/routes/api/personality/[id]/download/+server.js
Normal file
@@ -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)
|
||||
}
|
||||
});
|
||||
}
|
||||
35
src/routes/api/report/+server.js
Normal file
35
src/routes/api/report/+server.js
Normal file
@@ -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 });
|
||||
}
|
||||
98
src/routes/api/share/+server.js
Normal file
98
src/routes/api/share/+server.js
Normal file
@@ -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 });
|
||||
}
|
||||
209
src/routes/contact/+page.svelte
Normal file
209
src/routes/contact/+page.svelte
Normal file
@@ -0,0 +1,209 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
const SUBJECT_OPTIONS = [
|
||||
'General question',
|
||||
'Bug report',
|
||||
'Feature request',
|
||||
'Library content issue',
|
||||
'Other',
|
||||
];
|
||||
|
||||
let name = '';
|
||||
let email = '';
|
||||
let subject = '';
|
||||
let message = '';
|
||||
let honeypot = ''; // must stay empty
|
||||
let startTime = 0; // set on mount for timing check
|
||||
|
||||
let submitting = false;
|
||||
let submitted = false;
|
||||
let errorMsg = '';
|
||||
|
||||
onMount(() => { startTime = Date.now(); });
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
if (submitting) return;
|
||||
errorMsg = '';
|
||||
|
||||
if (!subject) { errorMsg = 'Please select a subject.'; return; }
|
||||
if (message.trim().length < 10) { errorMsg = 'Please write a bit more — at least 10 characters.'; return; }
|
||||
|
||||
submitting = true;
|
||||
try {
|
||||
const res = await fetch('/api/contact', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: name.trim(),
|
||||
email: email.trim(),
|
||||
subject,
|
||||
message: message.trim(),
|
||||
_hp: honeypot, // honeypot
|
||||
_ts: startTime, // timestamp for timing check
|
||||
})
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.message ?? `Error ${res.status}`);
|
||||
}
|
||||
|
||||
submitted = true;
|
||||
} catch (err) {
|
||||
errorMsg = err.message;
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Contact — ETC PRS Editor</title>
|
||||
</svelte:head>
|
||||
|
||||
<!-- Top bar -->
|
||||
<div style="position:sticky; top:0; z-index:100; background:var(--surface);
|
||||
border-bottom:1px solid var(--border); backdrop-filter:blur(12px);">
|
||||
<div style="max-width:1400px; margin:0 auto; padding:0 20px; height:52px;
|
||||
display:flex; align-items:center; gap:16px;">
|
||||
<div style="display:flex; align-items:center; gap:10px;
|
||||
border-right:1px solid var(--border); padding-right:16px; flex-shrink:0;">
|
||||
<div style="width:6px; height:26px; background:var(--amber);
|
||||
box-shadow:0 0 10px rgba(232,147,10,0.6); border-radius:2px;"></div>
|
||||
<div>
|
||||
<div style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:14px;
|
||||
letter-spacing:0.12em; text-transform:uppercase; color:var(--text);">ETC PRS</div>
|
||||
<div style="font-family:'DM Mono',monospace; font-size:9px; letter-spacing:0.1em;
|
||||
color:var(--cyan); opacity:0.8; text-transform:uppercase;">Contact</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="flex:1;"></div>
|
||||
<a class="btn" href="/" style="text-decoration:none;">← App</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="max-width:640px; margin:0 auto; padding:40px 20px;">
|
||||
|
||||
<div style="font-family:'DM Mono',monospace; font-size:11px; letter-spacing:0.12em;
|
||||
text-transform:uppercase; color:var(--text3); margin-bottom:10px;">
|
||||
Get in touch
|
||||
</div>
|
||||
<h1 style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:36px;
|
||||
letter-spacing:0.04em; text-transform:uppercase; color:var(--text);
|
||||
line-height:1.05; margin-bottom:8px;">
|
||||
Contact
|
||||
</h1>
|
||||
<p class="subtle" style="margin-bottom:32px; font-size:14px;">
|
||||
Have a question, found a bug, or want to suggest a feature? We'd love to hear from you.
|
||||
</p>
|
||||
|
||||
{#if submitted}
|
||||
<div class="panel" style="padding:32px; text-align:center;">
|
||||
<div style="font-size:28px; margin-bottom:12px; color:var(--green);">✓</div>
|
||||
<div style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:20px;
|
||||
letter-spacing:0.06em; text-transform:uppercase; color:var(--text);
|
||||
margin-bottom:8px;">Message Sent</div>
|
||||
<p style="font-size:14px; color:var(--text2); line-height:1.7;">
|
||||
Thanks for reaching out. We'll get back to you if a reply is needed.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<form on:submit={handleSubmit} novalidate>
|
||||
|
||||
<!-- Honeypot — visually hidden, must stay empty -->
|
||||
<div style="position:absolute; left:-9999px; opacity:0; pointer-events:none;"
|
||||
aria-hidden="true">
|
||||
<label for="__hp">Leave this blank</label>
|
||||
<input id="__hp" name="__hp" type="text" tabindex="-1" autocomplete="off"
|
||||
bind:value={honeypot} />
|
||||
</div>
|
||||
|
||||
<!-- Name + Email row -->
|
||||
<div style="display:grid; grid-template-columns:1fr 1fr; gap:14px; margin-bottom:14px;">
|
||||
<div>
|
||||
<label class="label" for="contact-name" style="display:block; margin-bottom:6px;">
|
||||
Name
|
||||
<span style="font-weight:400; text-transform:none; letter-spacing:0;
|
||||
color:var(--text3); font-family:'DM Mono',monospace; font-size:10px; margin-left:4px;">
|
||||
(optional)
|
||||
</span>
|
||||
</label>
|
||||
<input id="contact-name" class="input" type="text"
|
||||
bind:value={name} maxlength="100"
|
||||
placeholder="Your name" autocomplete="name" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label" for="contact-email" style="display:block; margin-bottom:6px;">
|
||||
Email
|
||||
<span style="font-weight:400; text-transform:none; letter-spacing:0;
|
||||
color:var(--text3); font-family:'DM Mono',monospace; font-size:10px; margin-left:4px;">
|
||||
(optional, for replies)
|
||||
</span>
|
||||
</label>
|
||||
<input id="contact-email" class="input" type="email"
|
||||
bind:value={email} maxlength="254"
|
||||
placeholder="you@example.com" autocomplete="email" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Subject -->
|
||||
<div style="margin-bottom:14px;">
|
||||
<label class="label" for="contact-subject" style="display:block; margin-bottom:6px;">
|
||||
Subject <span style="color:var(--red); margin-left:2px;">*</span>
|
||||
</label>
|
||||
<div style="display:flex; flex-wrap:wrap; gap:6px;">
|
||||
{#each SUBJECT_OPTIONS as opt}
|
||||
<button
|
||||
type="button"
|
||||
style="font-family:'Barlow Condensed',sans-serif; font-size:12px; font-weight:600;
|
||||
letter-spacing:0.06em; text-transform:uppercase; padding:5px 12px;
|
||||
border-radius:3px; cursor:pointer; transition:all 0.15s;
|
||||
border:1px solid {subject === opt ? 'var(--amber)' : 'var(--border2)'};
|
||||
background:{subject === opt ? 'var(--amber-dim)' : 'var(--bg)'};
|
||||
color:{subject === opt ? 'var(--amber)' : 'var(--text2)'};"
|
||||
on:click={() => subject = opt}
|
||||
>
|
||||
{opt}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Message -->
|
||||
<div style="margin-bottom:20px;">
|
||||
<label class="label" for="contact-message" style="display:block; margin-bottom:6px;">
|
||||
Message <span style="color:var(--red); margin-left:2px;">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="contact-message"
|
||||
class="input"
|
||||
style="resize:vertical; min-height:140px; font-size:14px; line-height:1.6;"
|
||||
bind:value={message}
|
||||
maxlength="3000"
|
||||
placeholder="Tell us what's on your mind…"
|
||||
></textarea>
|
||||
<div style="font-family:'DM Mono',monospace; font-size:10px; color:var(--text3);
|
||||
text-align:right; margin-top:4px;">
|
||||
{message.length} / 3000
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if errorMsg}
|
||||
<div style="margin-bottom:14px; padding:10px 12px; border-radius:3px;
|
||||
border:1px solid rgba(248,113,113,0.3); background:var(--red-dim);
|
||||
color:var(--red); font-size:13px;">
|
||||
{errorMsg}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button class="btn btn-primary" type="submit"
|
||||
style="width:100%; justify-content:center; padding:10px;"
|
||||
disabled={submitting}>
|
||||
{submitting ? 'Sending…' : 'Send Message'}
|
||||
</button>
|
||||
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
176
src/routes/disclosures/+page.svelte
Normal file
176
src/routes/disclosures/+page.svelte
Normal file
@@ -0,0 +1,176 @@
|
||||
<svelte:head>
|
||||
<title>Disclosures — ETC PRS Editor</title>
|
||||
</svelte:head>
|
||||
|
||||
<!-- Top bar -->
|
||||
<div style="position:sticky; top:0; z-index:100; background:var(--surface);
|
||||
border-bottom:1px solid var(--border); backdrop-filter:blur(12px);">
|
||||
<div style="max-width:1400px; margin:0 auto; padding:0 20px; height:52px;
|
||||
display:flex; align-items:center; gap:16px;">
|
||||
<div style="display:flex; align-items:center; gap:10px;
|
||||
border-right:1px solid var(--border); padding-right:16px; flex-shrink:0;">
|
||||
<div style="width:6px; height:26px; background:var(--amber);
|
||||
box-shadow:0 0 10px rgba(232,147,10,0.6); border-radius:2px;"></div>
|
||||
<div>
|
||||
<div style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:14px;
|
||||
letter-spacing:0.12em; text-transform:uppercase; color:var(--text);">ETC PRS</div>
|
||||
<div style="font-family:'DM Mono',monospace; font-size:9px; letter-spacing:0.1em;
|
||||
color:var(--cyan); opacity:0.8; text-transform:uppercase;">Disclosures</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="flex:1;"></div>
|
||||
<a class="btn" href="/about" style="text-decoration:none;">← About</a>
|
||||
<a class="btn" href="/" style="text-decoration:none;">App</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="max-width:760px; margin:0 auto; padding:40px 20px;">
|
||||
|
||||
<div style="font-family:'DM Mono',monospace; font-size:11px; letter-spacing:0.12em;
|
||||
text-transform:uppercase; color:var(--text3); margin-bottom:10px;">
|
||||
Transparency
|
||||
</div>
|
||||
<h1 style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:36px;
|
||||
letter-spacing:0.04em; text-transform:uppercase; color:var(--text);
|
||||
line-height:1.05; margin-bottom:32px;">
|
||||
Disclosures
|
||||
</h1>
|
||||
|
||||
<!-- Affiliation disclaimer -->
|
||||
<section style="margin-bottom:36px;">
|
||||
<div style="display:flex; align-items:center; gap:8px; margin-bottom:14px;">
|
||||
<div style="width:3px; height:20px; background:var(--amber); border-radius:2px;"></div>
|
||||
<h2 style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:18px;
|
||||
letter-spacing:0.08em; text-transform:uppercase; color:var(--text);">
|
||||
Affiliation & Endorsement
|
||||
</h2>
|
||||
</div>
|
||||
<div class="panel" style="padding:18px 20px;">
|
||||
<p style="font-size:14px; color:var(--text2); line-height:1.8; margin-bottom:12px;">
|
||||
This project is <strong style="color:var(--text);">not affiliated with, sponsored by,
|
||||
or endorsed by ETC (Electronic Theatre Controls, Inc.)</strong> in any way.
|
||||
</p>
|
||||
<p style="font-size:14px; color:var(--text2); line-height:1.8; margin-bottom:12px;">
|
||||
ETC, Expression, and related product names are trademarks of Electronic Theatre Controls, Inc.
|
||||
All trademarks are the property of their respective owners.
|
||||
</p>
|
||||
<p style="font-size:14px; color:var(--text2); line-height:1.8;">
|
||||
This tool is an independent, community-built project intended for educational purposes,
|
||||
interoperability, and supporting legacy lighting systems.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Reverse engineering -->
|
||||
<section style="margin-bottom:36px;">
|
||||
<div style="display:flex; align-items:center; gap:8px; margin-bottom:14px;">
|
||||
<div style="width:3px; height:20px; background:var(--cyan); border-radius:2px;"></div>
|
||||
<h2 style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:18px;
|
||||
letter-spacing:0.08em; text-transform:uppercase; color:var(--text);">
|
||||
Reverse Engineering
|
||||
</h2>
|
||||
</div>
|
||||
<div class="panel" style="padding:18px 20px;">
|
||||
<p style="font-size:14px; color:var(--text2); line-height:1.8; margin-bottom:14px;">
|
||||
The <code style="font-family:'DM Mono',monospace; font-size:12px; color:var(--amber);
|
||||
background:var(--raised); padding:1px 5px; border-radius:2px;">.prs</code> 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:
|
||||
</p>
|
||||
<ul style="list-style:none; padding:0; display:flex; flex-direction:column; gap:8px; margin-bottom:14px;">
|
||||
{#each reverseEngineeringPoints as point}
|
||||
<li style="display:flex; gap:10px; align-items:flex-start;">
|
||||
<span style="color:var(--cyan); flex-shrink:0; font-family:'DM Mono',monospace;">→</span>
|
||||
<span style="font-size:14px; color:var(--text2); line-height:1.6;">{point}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<p style="font-size:14px; color:var(--text2); line-height:1.8;">
|
||||
No proprietary source code was accessed or used. The reverse engineering was limited
|
||||
strictly to understanding the file structure for the purpose of interoperability.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- AI disclosure -->
|
||||
<section style="margin-bottom:36px;">
|
||||
<div style="display:flex; align-items:center; gap:8px; margin-bottom:14px;">
|
||||
<div style="width:3px; height:20px; background:var(--magenta); border-radius:2px;"></div>
|
||||
<h2 style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:18px;
|
||||
letter-spacing:0.08em; text-transform:uppercase; color:var(--text);">
|
||||
AI-Assisted Development
|
||||
</h2>
|
||||
</div>
|
||||
<div class="panel" style="padding:18px 20px;">
|
||||
<p style="font-size:14px; color:var(--text2); line-height:1.8; margin-bottom:14px;">
|
||||
Significant portions of this application were developed with the assistance of
|
||||
AI language models, including:
|
||||
</p>
|
||||
<ul style="list-style:none; padding:0; display:flex; flex-direction:column; gap:8px; margin-bottom:14px;">
|
||||
{#each aiPoints as point}
|
||||
<li style="display:flex; gap:10px; align-items:flex-start;">
|
||||
<span style="color:var(--magenta); flex-shrink:0; font-family:'DM Mono',monospace;">→</span>
|
||||
<span style="font-size:14px; color:var(--text2); line-height:1.6;">{point}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<p style="font-size:14px; color:var(--text2); line-height:1.8; margin-bottom:12px;">
|
||||
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.
|
||||
</p>
|
||||
<p style="font-size:14px; color:var(--text2); line-height:1.8;">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Intent -->
|
||||
<section>
|
||||
<div style="display:flex; align-items:center; gap:8px; margin-bottom:14px;">
|
||||
<div style="width:3px; height:20px; background:var(--green); border-radius:2px;"></div>
|
||||
<h2 style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:18px;
|
||||
letter-spacing:0.08em; text-transform:uppercase; color:var(--text);">
|
||||
Intent & Scope
|
||||
</h2>
|
||||
</div>
|
||||
<div class="panel" style="padding:18px 20px;">
|
||||
<p style="font-size:14px; color:var(--text2); line-height:1.8; margin-bottom:14px;">
|
||||
This project exists to serve the lighting community. It is intended for:
|
||||
</p>
|
||||
<ul style="list-style:none; padding:0; display:flex; flex-direction:column; gap:8px;">
|
||||
{#each intentPoints as point}
|
||||
<li style="display:flex; gap:10px; align-items:flex-start;">
|
||||
<span style="color:var(--green); flex-shrink:0;">✓</span>
|
||||
<span style="font-size:14px; color:var(--text2); line-height:1.6;">{point}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const reverseEngineeringPoints = [
|
||||
'Inspection and comparison of real .prs files produced by ETC\'s Personality Editor',
|
||||
'Behavioral analysis — observing how changes in the editor affect the binary output',
|
||||
'Static analysis of the Personality Editor executable to understand byte-level structure',
|
||||
];
|
||||
|
||||
const aiPoints = [
|
||||
'UI implementation and component design',
|
||||
'Data handling logic and state management',
|
||||
'Iterative refinement of features based on developer feedback',
|
||||
'The public library, admin panel, and moderation system',
|
||||
];
|
||||
|
||||
const intentPoints = [
|
||||
'Educational purposes — understanding how fixture personalities are structured',
|
||||
'Interoperability — making it easier to work with legacy ETC systems',
|
||||
'Community tooling — sharing fixture personalities with other technicians',
|
||||
'Supporting legacy systems that may not have modern tooling available',
|
||||
];
|
||||
</script>
|
||||
43
src/routes/library/+page.server.js
Normal file
43
src/routes/library/+page.server.js
Normal file
@@ -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; }
|
||||
}
|
||||
421
src/routes/library/+page.svelte
Normal file
421
src/routes/library/+page.svelte
Normal file
@@ -0,0 +1,421 @@
|
||||
<script>
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { Search, LayoutGrid, Table2, Download, ArrowUpRight, ChevronLeft, ChevronRight } from 'lucide-svelte';
|
||||
import { saveStoredFile, getStoredFiles, getSnapshots } from '$lib/storage.js';
|
||||
import { normalizePersonality, parsePRS } from '$lib/prs.js';
|
||||
import { makeSlug } from '$lib/shared/slugify.js';
|
||||
import GlobalMenu from '$lib/components/GlobalMenu.svelte';
|
||||
|
||||
export let data;
|
||||
|
||||
let searchInput = data.q;
|
||||
let searchTimer;
|
||||
|
||||
// For GlobalMenu — load from localStorage so recent files / snapshots work
|
||||
import { browser } from '$app/environment';
|
||||
let previousFiles = [];
|
||||
let snapshots = [];
|
||||
if (browser) {
|
||||
previousFiles = getStoredFiles();
|
||||
}
|
||||
|
||||
async function handleOpenFile(event) {
|
||||
const file = event.currentTarget.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
const buf = await file.arrayBuffer();
|
||||
const personality = normalizePersonality(parsePRS(buf));
|
||||
const stored = saveStoredFile(personality, file.name);
|
||||
localStorage.setItem(AUTOSAVE_KEY, JSON.stringify({
|
||||
personality,
|
||||
mode: 'viewer',
|
||||
currentFileId: stored.id,
|
||||
notes: ''
|
||||
}));
|
||||
goto('/');
|
||||
} catch { /* ignore */ } finally {
|
||||
event.currentTarget.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
const AUTOSAVE_KEY = 'etc-prs-ui-autosave-v1';
|
||||
|
||||
async function loadIntoEditor(item) {
|
||||
try {
|
||||
const res = await fetch(`/api/personality/${item.id}/download`);
|
||||
if (!res.ok) throw new Error('Download failed');
|
||||
const buf = await res.arrayBuffer();
|
||||
const personality = normalizePersonality(parsePRS(buf));
|
||||
const stored = saveStoredFile(personality, item.file_name ?? `${item.name}.prs`, item.notes ?? '');
|
||||
// Write directly into autosave so main app restores this file, not a stale session
|
||||
localStorage.setItem(AUTOSAVE_KEY, JSON.stringify({
|
||||
personality,
|
||||
mode: 'viewer',
|
||||
currentFileId: stored.id,
|
||||
notes: item.notes ?? ''
|
||||
}));
|
||||
goto('/');
|
||||
} catch (err) {
|
||||
alert(`Failed to load personality: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function openStoredFile(item) {
|
||||
const personality = normalizePersonality(item.parsed);
|
||||
// Write into autosave so main app restores this specific file
|
||||
localStorage.setItem(AUTOSAVE_KEY, JSON.stringify({
|
||||
personality,
|
||||
mode: 'viewer',
|
||||
currentFileId: item.id,
|
||||
notes: item.notes ?? ''
|
||||
}));
|
||||
goto('/');
|
||||
}
|
||||
|
||||
function openSnapshot(item) {
|
||||
// Snapshots don't have a stored file ID — just set mode to editor
|
||||
localStorage.setItem(AUTOSAVE_KEY, JSON.stringify({
|
||||
personality: normalizePersonality(item.parsed),
|
||||
mode: 'editor',
|
||||
currentFileId: null,
|
||||
notes: ''
|
||||
}));
|
||||
goto('/');
|
||||
}
|
||||
|
||||
function attrColor(count) {
|
||||
if (count >= 24) return 'var(--magenta)';
|
||||
if (count >= 12) return 'var(--cyan)';
|
||||
if (count >= 6) return 'var(--amber)';
|
||||
return 'var(--text2)';
|
||||
}
|
||||
|
||||
function navigate(params) {
|
||||
const u = new URL($page.url);
|
||||
// Always preserve limit unless explicitly changed
|
||||
if (!params.limit && data.limit !== 24) {
|
||||
u.searchParams.set('limit', String(data.limit));
|
||||
}
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v !== '' && v !== null && v !== undefined) u.searchParams.set(k, String(v));
|
||||
else u.searchParams.delete(k);
|
||||
}
|
||||
goto(u.toString(), { keepFocus: true });
|
||||
}
|
||||
|
||||
function onSearch() {
|
||||
clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(() => navigate({ q: searchInput, page: '1' }), 350);
|
||||
}
|
||||
|
||||
|
||||
function formatDate(iso) {
|
||||
return new Date(iso).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Library — ETC PRS Editor</title>
|
||||
</svelte:head>
|
||||
|
||||
<GlobalMenu
|
||||
currentMode="viewer"
|
||||
{previousFiles}
|
||||
{snapshots}
|
||||
onOpenFile={handleOpenFile}
|
||||
onNewFile={() => goto('/')}
|
||||
onOpenStoredFile={openStoredFile}
|
||||
onOpenSnapshot={openSnapshot}
|
||||
onGoHome={() => goto('/')}
|
||||
/>
|
||||
|
||||
<!-- Search + filter bar -->
|
||||
<div style="background:var(--raised); border-bottom:1px solid var(--border); padding:10px 20px;">
|
||||
<div style="width:100%; max-width:1400px; margin:0 auto; box-sizing:border-box; display:flex; flex-direction:column; gap:8px;">
|
||||
|
||||
<!-- Row 1: Search + Manufacturer -->
|
||||
<div style="display:flex; flex-wrap:wrap; align-items:center; gap:8px;">
|
||||
<div style="position:relative; flex:1; min-width:200px;">
|
||||
<Search size={14} style="position:absolute; left:10px; top:50%; transform:translateY(-50%); color:var(--text3); pointer-events:none;" />
|
||||
<input
|
||||
class="input"
|
||||
style="padding-left:32px;"
|
||||
type="search"
|
||||
placeholder="Search fixtures, manufacturers, tags…"
|
||||
bind:value={searchInput}
|
||||
on:input={onSearch}
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
class="select"
|
||||
style="min-width:200px; max-width:280px;"
|
||||
value={data.manufacturer}
|
||||
on:change={(e) => navigate({ manufacturer: e.currentTarget.value, page: '1' })}
|
||||
>
|
||||
<option value="">All Manufacturers ({data.total})</option>
|
||||
{#each data.manufacturers as m}
|
||||
{#if data.manufacturerCounts[m]}
|
||||
<option value={m}>{m} ({data.manufacturerCounts[m]})</option>
|
||||
{/if}
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Row 2: Sort + Per-page + spacer + View toggle + Result count -->
|
||||
<div style="display:flex; align-items:center; gap:8px;">
|
||||
|
||||
<!-- Sort -->
|
||||
<select
|
||||
class="select"
|
||||
style="min-width:140px;"
|
||||
value={data.sort}
|
||||
on:change={(e) => navigate({ sort: e.currentTarget.value, page: '1' })}
|
||||
>
|
||||
<option value="newest">Newest First</option>
|
||||
<option value="popular">Most Viewed</option>
|
||||
</select>
|
||||
|
||||
<!-- Per-page -->
|
||||
<select
|
||||
class="select"
|
||||
style="min-width:110px;"
|
||||
value={data.limit}
|
||||
on:change={(e) => navigate({ limit: e.currentTarget.value, page: '1' })}
|
||||
>
|
||||
<option value={12}>12 / page</option>
|
||||
<option value={24}>24 / page</option>
|
||||
<option value={48}>48 / page</option>
|
||||
<option value={96}>96 / page</option>
|
||||
</select>
|
||||
|
||||
<!-- Spacer -->
|
||||
<div style="flex:1;"></div>
|
||||
|
||||
<!-- Result count -->
|
||||
<div style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:16px;
|
||||
letter-spacing:0.04em; color:var(--text2); white-space:nowrap; flex-shrink:0;">
|
||||
<span style="font-family:'DM Mono',monospace; font-size:18px; color:var(--amber);
|
||||
text-shadow:0 0 10px rgba(232,147,10,0.35);">{data.total}</span>
|
||||
<span style="font-size:13px; color:var(--text3); margin-left:4px;">
|
||||
result{data.total !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<div style="width:1px; height:22px; background:var(--border2); flex-shrink:0;"></div>
|
||||
|
||||
<!-- View toggle -->
|
||||
<div style="display:flex; border:1px solid var(--border2); border-radius:3px; overflow:hidden; flex-shrink:0;">
|
||||
<button class="btn"
|
||||
style="border:none; border-radius:0; padding:6px 12px;
|
||||
{data.view === 'cards' ? 'background:var(--amber-dim); color:var(--amber);' : 'background:transparent; color:var(--text3);'}"
|
||||
type="button" on:click={() => navigate({ view: 'cards' })}>
|
||||
<LayoutGrid size={16} />
|
||||
</button>
|
||||
<div style="width:1px; background:var(--border2);"></div>
|
||||
<button class="btn"
|
||||
style="border:none; border-radius:0; padding:6px 12px;
|
||||
{data.view === 'table' ? 'background:var(--amber-dim); color:var(--amber);' : 'background:transparent; color:var(--text3);'}"
|
||||
type="button" on:click={() => navigate({ view: 'table' })}>
|
||||
<Table2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main content -->
|
||||
<div style="width:100%; max-width:1400px; margin:0 auto; padding:20px; box-sizing:border-box;">
|
||||
|
||||
{#if data.items.length === 0}
|
||||
<div style="text-align:center; padding:80px 20px; color:var(--text3);">
|
||||
<div style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:20px; color:var(--text2); margin-bottom:8px;">No personalities found</div>
|
||||
<div style="font-size:13px;">Try a different search or clear the filters.</div>
|
||||
</div>
|
||||
|
||||
{:else if data.view === 'table'}
|
||||
<!-- Table view -->
|
||||
<div class="panel" style="overflow:hidden; margin-bottom:20px;">
|
||||
<div style="overflow-x:auto;">
|
||||
<table style="width:100%; border-collapse:collapse;">
|
||||
<thead>
|
||||
<tr style="background:var(--raised);">
|
||||
{#each ['Fixture', 'Manufacturer', 'Channels', 'Tags', 'By', 'Date', 'Views', ''] as h}
|
||||
<th style="padding:10px 14px; text-align:left; font-family:'Barlow Condensed',sans-serif;
|
||||
font-size:11px; font-weight:700; letter-spacing:0.12em; text-transform:uppercase;
|
||||
color:var(--text2); border-bottom:1px solid var(--border); white-space:nowrap;">
|
||||
{h}
|
||||
</th>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each data.items as item}
|
||||
<tr style="border-bottom:1px solid var(--border); transition:background 0.1s;"
|
||||
on:mouseenter={(e) => e.currentTarget.style.background = 'var(--raised)'}
|
||||
on:mouseleave={(e) => e.currentTarget.style.background = ''}>
|
||||
<td style="padding:10px 14px;">
|
||||
<a href="/p/{item.id}/{makeSlug(item.name)}"
|
||||
style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:16px;
|
||||
color:var(--text); text-decoration:none; letter-spacing:0.03em;">
|
||||
{item.name}
|
||||
</a>
|
||||
{#if item.prs_name && item.prs_name !== item.name}
|
||||
<div style="font-family:'DM Mono',monospace; font-size:10px; color:var(--amber);
|
||||
margin-top:2px;">PRS: {item.prs_name}</div>
|
||||
{/if}
|
||||
{#if item.file_name}
|
||||
<div style="font-family:'DM Mono',monospace; font-size:10px; color:var(--text3);">{item.file_name}</div>
|
||||
{/if}
|
||||
</td>
|
||||
<td style="padding:10px 14px; font-size:13px; color:var(--text2); white-space:nowrap;">
|
||||
{item.manufacturer || '—'}
|
||||
</td>
|
||||
<td style="padding:10px 14px; font-family:'DM Mono',monospace; font-size:14px; color:{attrColor(item.channel_count)}; white-space:nowrap;">
|
||||
{item.channel_count}
|
||||
</td>
|
||||
<td style="padding:10px 14px; max-width:200px;">
|
||||
<div style="display:flex; flex-wrap:wrap; gap:4px;">
|
||||
{#each item.tags as tag}
|
||||
<span class="badge">{tag}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</td>
|
||||
<td style="padding:10px 14px; font-size:12px; color:var(--text3); white-space:nowrap;">
|
||||
{item.creator_handle || '—'}
|
||||
</td>
|
||||
<td style="padding:10px 14px; font-family:'DM Mono',monospace; font-size:11px; color:var(--text3); white-space:nowrap;">
|
||||
{formatDate(item.created_at)}
|
||||
</td>
|
||||
<td style="padding:10px 14px; font-family:'DM Mono',monospace; font-size:11px; color:var(--text3); white-space:nowrap;">
|
||||
{item.view_count}
|
||||
</td>
|
||||
<td style="padding:10px 10px; white-space:nowrap;">
|
||||
<div style="display:flex; gap:5px;">
|
||||
<button class="btn" style="padding:5px 8px;" type="button"
|
||||
on:click={() => loadIntoEditor(item)} title="Load into editor">
|
||||
<ArrowUpRight size={13} />
|
||||
</button>
|
||||
<a class="btn" href="/api/personality/{item.id}/download" download
|
||||
style="padding:5px 8px; text-decoration:none;" title="Download .prs">
|
||||
<Download size={13} />
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
<!-- Card view -->
|
||||
<div style="display:grid; grid-template-columns:repeat(auto-fill, minmax(240px, 1fr)); gap:10px; margin-bottom:20px;">
|
||||
{#each data.items as item}
|
||||
<div class="panel" style="display:flex; flex-direction:column; overflow:hidden; min-width:0;
|
||||
border-top:2px solid {attrColor(item.channel_count)}; transition:border-color 0.15s;">
|
||||
<!-- Card header -->
|
||||
<div style="padding:12px 14px 10px; border-bottom:1px solid var(--border); min-width:0;">
|
||||
<div style="font-family:'DM Mono',monospace; font-size:10px; color:var(--text3); margin-bottom:4px;
|
||||
overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">
|
||||
{item.manufacturer || 'Unknown'}
|
||||
</div>
|
||||
<a href="/p/{item.id}/{makeSlug(item.name)}"
|
||||
style="display:block; font-family:'Barlow Condensed',sans-serif; font-weight:700;
|
||||
font-size:20px; letter-spacing:0.03em; color:var(--text); text-decoration:none;
|
||||
line-height:1.2; margin-bottom:2px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;"
|
||||
title={item.name}>
|
||||
{item.name}
|
||||
</a>
|
||||
{#if item.prs_name && item.prs_name !== item.name}
|
||||
<div style="font-family:'DM Mono',monospace; font-size:10px; color:var(--amber);
|
||||
margin-bottom:2px;">PRS: {item.prs_name}</div>
|
||||
{/if}
|
||||
{#if item.file_name}
|
||||
<div style="font-family:'DM Mono',monospace; font-size:10px; color:var(--text3);
|
||||
overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">
|
||||
{item.file_name}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Card body -->
|
||||
<div style="padding:10px 14px; flex:1;">
|
||||
<!-- Channel count LED -->
|
||||
<div style="display:flex; align-items:baseline; gap:6px; margin-bottom:8px;">
|
||||
<div style="font-family:'DM Mono',monospace; font-size:26px; font-weight:500;
|
||||
color:{attrColor(item.channel_count)};
|
||||
text-shadow:0 0 12px {attrColor(item.channel_count)}40; line-height:1;">
|
||||
{item.channel_count}
|
||||
</div>
|
||||
<div class="label">CH</div>
|
||||
</div>
|
||||
|
||||
<!-- Tags -->
|
||||
{#if item.tags.length}
|
||||
<div style="display:flex; flex-wrap:wrap; gap:4px; margin-bottom:8px;">
|
||||
{#each item.tags as tag}
|
||||
<span class="badge">{tag}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Meta -->
|
||||
<div style="font-family:'DM Mono',monospace; font-size:10px; color:var(--text3); line-height:1.8;">
|
||||
{#if item.creator_handle}<div>By {item.creator_handle}</div>{/if}
|
||||
<div>{formatDate(item.created_at)} · {item.view_count} views</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card footer actions -->
|
||||
<div style="padding:8px 14px; border-top:1px solid var(--border); display:flex; gap:6px;">
|
||||
<button class="btn" style="flex:1; justify-content:center;" type="button"
|
||||
on:click={() => loadIntoEditor(item)}>
|
||||
<ArrowUpRight size={13} /> Load into Editor
|
||||
</button>
|
||||
<a class="btn" href="/api/personality/{item.id}/download" download
|
||||
style="padding:6px 8px; text-decoration:none;" title="Download .prs">
|
||||
<Download size={13} />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Pagination -->
|
||||
{#if data.pages > 1}
|
||||
<div style="display:flex; align-items:center; justify-content:center; gap:4px; padding:8px 0;">
|
||||
<button class="btn" style="padding:6px 10px;" type="button"
|
||||
disabled={data.page <= 1}
|
||||
on:click={() => navigate({ page: String(data.page - 1) })}>
|
||||
<ChevronLeft size={14} />
|
||||
</button>
|
||||
|
||||
{#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}
|
||||
<button
|
||||
class="btn"
|
||||
style="padding:6px 12px; min-width:36px; justify-content:center;
|
||||
font-family:'DM Mono',monospace; font-size:12px;
|
||||
{p === data.page ? 'background:var(--amber-dim); border-color:var(--amber); color:var(--amber);' : ''}"
|
||||
type="button"
|
||||
on:click={() => navigate({ page: String(p) })}
|
||||
>{p}</button>
|
||||
{:else if Math.abs(p - data.page) === 3}
|
||||
<span style="color:var(--text3); padding:0 4px; font-family:'DM Mono',monospace;">…</span>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<button class="btn" style="padding:6px 10px;" type="button"
|
||||
disabled={data.page >= data.pages}
|
||||
on:click={() => navigate({ page: String(data.page + 1) })}>
|
||||
<ChevronRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
24
src/routes/p/[id]/[slug]/+page.server.js
Normal file
24
src/routes/p/[id]/[slug]/+page.server.js
Normal file
@@ -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; }
|
||||
}
|
||||
339
src/routes/p/[id]/[slug]/+page.svelte
Normal file
339
src/routes/p/[id]/[slug]/+page.svelte
Normal file
@@ -0,0 +1,339 @@
|
||||
<script>
|
||||
import { goto } from '$app/navigation';
|
||||
import { browser } from '$app/environment';
|
||||
import { Download, ArrowUpRight, Trash2, Eye, ChevronDown, Flag } from 'lucide-svelte';
|
||||
import ChannelCardGrid from '$lib/components/ChannelCardGrid.svelte';
|
||||
import ChannelTable from '$lib/components/ChannelTable.svelte';
|
||||
import ReportModal from '$lib/components/ReportModal.svelte';
|
||||
import { saveStoredFile, getStoredFiles, detachSharedRecord } from '$lib/storage.js';
|
||||
import { normalizePersonality, parsePRS, buildVisibleEntries } from '$lib/prs.js';
|
||||
|
||||
export let data;
|
||||
|
||||
const p = data.personality;
|
||||
|
||||
let view = 'cards';
|
||||
let showDeleteForm = false;
|
||||
let showReportModal = false;
|
||||
let deleteToken = '';
|
||||
let deleting = false;
|
||||
let deleteError = '';
|
||||
let deleteSuccess = false;
|
||||
let isOwner = false;
|
||||
|
||||
// Check localStorage for a saved token matching this personality's shared_id
|
||||
if (browser) {
|
||||
const files = getStoredFiles();
|
||||
const match = files.find((f) => f.shared_id === p.id);
|
||||
if (match?.owner_token) {
|
||||
deleteToken = match.owner_token;
|
||||
isOwner = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse the personality for display
|
||||
let displayPersonality = null;
|
||||
let visibleEntries = [];
|
||||
|
||||
async function loadDisplay() {
|
||||
try {
|
||||
const res = await fetch(`/api/personality/${p.id}/download`);
|
||||
if (!res.ok) return;
|
||||
const buf = await res.arrayBuffer();
|
||||
displayPersonality = normalizePersonality(parsePRS(buf));
|
||||
visibleEntries = buildVisibleEntries(displayPersonality.channels);
|
||||
} catch { /* non-fatal — display still works without channel detail */ }
|
||||
}
|
||||
|
||||
loadDisplay();
|
||||
|
||||
async function loadIntoEditor() {
|
||||
try {
|
||||
const res = await fetch(`/api/personality/${p.id}/download`);
|
||||
if (!res.ok) throw new Error('Download failed');
|
||||
const buf = await res.arrayBuffer();
|
||||
const personality = normalizePersonality(parsePRS(buf));
|
||||
saveStoredFile(personality, p.file_name ?? `${p.name}.prs`);
|
||||
goto('/');
|
||||
} catch (err) {
|
||||
alert(`Failed to load: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!deleteToken.trim()) { deleteError = 'Please enter your owner token.'; return; }
|
||||
deleting = true;
|
||||
deleteError = '';
|
||||
try {
|
||||
const res = await fetch('/api/delete', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: p.id, owner_token: deleteToken.trim() })
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.message ?? `Error ${res.status}`);
|
||||
}
|
||||
deleteSuccess = true;
|
||||
detachSharedRecord(p.id);
|
||||
setTimeout(() => goto('/library'), 2000);
|
||||
} catch (err) {
|
||||
deleteError = err.message;
|
||||
deleting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(iso) {
|
||||
return new Date(iso).toLocaleDateString(undefined, { year:'numeric', month:'long', day:'numeric' });
|
||||
}
|
||||
|
||||
function attrColor(count) {
|
||||
if (count >= 24) return 'var(--magenta)';
|
||||
if (count >= 12) return 'var(--cyan)';
|
||||
if (count >= 6) return 'var(--amber)';
|
||||
return 'var(--text2)';
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{p.name} — ETC PRS Library</title>
|
||||
<meta name="description" content="{p.name} by {p.manufacturer ?? 'Unknown'} — {p.channel_count} channel ETC PRS personality" />
|
||||
</svelte:head>
|
||||
|
||||
<!-- Top bar -->
|
||||
<div style="position:sticky; top:0; z-index:100; background:var(--surface); border-bottom:1px solid var(--border); backdrop-filter:blur(12px);">
|
||||
<div style="max-width:1400px; margin:0 auto; padding:0 20px; height:52px; display:flex; align-items:center; gap:16px;">
|
||||
<div style="display:flex; align-items:center; gap:10px; border-right:1px solid var(--border); padding-right:16px; flex-shrink:0;">
|
||||
<div style="width:6px; height:26px; background:var(--amber); box-shadow:0 0 10px rgba(232,147,10,0.6); border-radius:2px;"></div>
|
||||
<div>
|
||||
<div style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:14px; letter-spacing:0.12em; text-transform:uppercase; color:var(--text);">ETC PRS</div>
|
||||
<div style="font-family:'DM Mono',monospace; font-size:9px; letter-spacing:0.1em; color:var(--cyan); opacity:0.8; text-transform:uppercase;">Personality Library</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="flex:1;"></div>
|
||||
<a class="btn" href="/library" style="text-decoration:none;">← Library</a>
|
||||
<a class="btn" href="/" style="text-decoration:none;">App</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="max-width:1400px; margin:0 auto; padding:20px;">
|
||||
|
||||
<!-- Personality header panel -->
|
||||
<div class="panel" style="padding:0; overflow:hidden; margin-bottom:16px;">
|
||||
|
||||
<!-- Accent strip coloured by channel count -->
|
||||
<div style="height:3px; background:{attrColor(p.channel_count)};
|
||||
box-shadow:0 0 12px {attrColor(p.channel_count)}60;"></div>
|
||||
|
||||
<div style="padding:20px 24px;">
|
||||
<!-- Manufacturer + name -->
|
||||
{#if p.manufacturer}
|
||||
<div style="font-family:'DM Mono',monospace; font-size:11px; color:var(--text3);
|
||||
text-transform:uppercase; letter-spacing:0.1em; margin-bottom:6px;">
|
||||
{p.manufacturer}
|
||||
</div>
|
||||
{/if}
|
||||
<h1 style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:32px;
|
||||
letter-spacing:0.04em; color:var(--text); line-height:1.1; margin-bottom:4px;">
|
||||
{p.name}
|
||||
</h1>
|
||||
{#if p.prs_name && p.prs_name !== p.name}
|
||||
<div style="font-family:'DM Mono',monospace; font-size:12px; color:var(--amber);
|
||||
margin-bottom:8px; display:flex; align-items:center; gap:6px;">
|
||||
<span style="color:var(--text3);">PRS name:</span>
|
||||
<span>{p.prs_name}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Stats row -->
|
||||
<div style="display:flex; flex-wrap:wrap; align-items:center; gap:16px; margin-bottom:12px;">
|
||||
<div style="display:flex; align-items:baseline; gap:5px;">
|
||||
<div style="font-family:'DM Mono',monospace; font-size:28px; font-weight:500;
|
||||
color:{attrColor(p.channel_count)};
|
||||
text-shadow:0 0 12px {attrColor(p.channel_count)}40; line-height:1;">
|
||||
{p.channel_count}
|
||||
</div>
|
||||
<div class="label">Channels</div>
|
||||
</div>
|
||||
<div style="display:flex; align-items:center; gap:5px; color:var(--text3);">
|
||||
<Eye size={13} />
|
||||
<span style="font-family:'DM Mono',monospace; font-size:12px;">{p.view_count} views</span>
|
||||
</div>
|
||||
{#if p.creator_handle}
|
||||
<div style="font-size:12px; color:var(--text2);">By <strong>{p.creator_handle}</strong></div>
|
||||
{/if}
|
||||
<div style="font-family:'DM Mono',monospace; font-size:11px; color:var(--text3);">
|
||||
{formatDate(p.created_at)}
|
||||
</div>
|
||||
{#if p.file_name}
|
||||
<div style="font-family:'DM Mono',monospace; font-size:11px; color:var(--text3);">{p.file_name}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Tags -->
|
||||
{#if p.tags.length}
|
||||
<div style="display:flex; flex-wrap:wrap; gap:5px; margin-bottom:14px;">
|
||||
{#each p.tags as tag}
|
||||
<span class="badge">{tag}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Notes -->
|
||||
{#if p.notes}
|
||||
<div style="padding:10px 12px; border-radius:3px; border:1px solid var(--border);
|
||||
background:var(--raised); font-size:13px; color:var(--text2);
|
||||
line-height:1.6; margin-bottom:14px;">
|
||||
{p.notes}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Actions -->
|
||||
<div style="display:flex; flex-wrap:wrap; align-items:center; gap:8px;">
|
||||
{#if !p.deleted_at}
|
||||
<button class="btn btn-primary" type="button" on:click={loadIntoEditor}>
|
||||
<ArrowUpRight size={14} /> Load into Editor
|
||||
</button>
|
||||
<a class="btn" href="/api/personality/{p.id}/download" download="{p.name}.prs"
|
||||
style="text-decoration:none;">
|
||||
<Download size={14} /> Download .prs
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
<!-- Report button — subtle, right-aligned -->
|
||||
<button
|
||||
class="btn"
|
||||
style="margin-left:auto; color:var(--text3);"
|
||||
type="button"
|
||||
on:click={() => showReportModal = true}
|
||||
>
|
||||
<Flag size={13} /> Report
|
||||
</button>
|
||||
|
||||
<!-- Delete toggle — shown to everyone, but pre-filled for owner -->
|
||||
{#if !p.deleted_at}
|
||||
<button
|
||||
class="btn"
|
||||
style="{isOwner ? 'color:var(--red); border-color:rgba(248,113,113,0.3);' : 'color:var(--text3);'}"
|
||||
type="button"
|
||||
on:click={() => { showDeleteForm = !showDeleteForm; deleteError = ''; }}
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
{isOwner ? 'Delete (you own this)' : 'I own this'}
|
||||
<ChevronDown size={12} style="transition:transform 0.2s; {showDeleteForm ? 'transform:rotate(180deg)' : ''}" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Soft-deleted notice -->
|
||||
{#if p.deleted_at}
|
||||
<div style="margin-top:16px; padding:14px; border-radius:3px;
|
||||
border:1px solid rgba(248,113,113,0.25); background:var(--red-dim);
|
||||
color:var(--red); font-size:13px; line-height:1.6;">
|
||||
<strong style="font-family:'Barlow Condensed',sans-serif; font-size:14px;
|
||||
letter-spacing:0.06em; text-transform:uppercase;">
|
||||
This personality has been removed.
|
||||
</strong><br/>
|
||||
It is no longer available in the library. If you believe this was in error,
|
||||
please <a href="/" style="color:var(--red);">contact us</a>.
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Delete form — collapsible -->
|
||||
{#if showDeleteForm}
|
||||
<div style="margin-top:14px; padding:14px; border-radius:3px;
|
||||
border:1px solid rgba(248,113,113,0.25); background:var(--red-dim);">
|
||||
{#if deleteSuccess}
|
||||
<div style="font-family:'Barlow Condensed',sans-serif; font-size:14px; font-weight:600;
|
||||
color:var(--green); letter-spacing:0.04em;">
|
||||
Deleted. Redirecting to library…
|
||||
</div>
|
||||
{:else}
|
||||
<div class="label" style="color:var(--red); margin-bottom:8px;">Delete this personality</div>
|
||||
<p style="font-size:12px; color:var(--text2); margin-bottom:10px; line-height:1.6;">
|
||||
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}
|
||||
</p>
|
||||
<div style="display:flex; gap:8px;">
|
||||
{#if isOwner}
|
||||
<input
|
||||
class="input"
|
||||
style="flex:1; font-family:'DM Mono',monospace; font-size:12px;"
|
||||
type="text"
|
||||
bind:value={deleteToken}
|
||||
readonly
|
||||
/>
|
||||
{:else}
|
||||
<input
|
||||
class="input"
|
||||
style="flex:1; font-family:'DM Mono',monospace; font-size:12px;"
|
||||
type="password"
|
||||
placeholder="Paste your owner token here…"
|
||||
bind:value={deleteToken}
|
||||
on:keydown={(e) => e.key === 'Enter' && handleDelete()}
|
||||
/>
|
||||
{/if}
|
||||
<button class="btn btn-danger" type="button" on:click={handleDelete} disabled={deleting}>
|
||||
<Trash2 size={13} />
|
||||
{deleting ? 'Deleting…' : 'Delete'}
|
||||
</button>
|
||||
</div>
|
||||
{#if deleteError}
|
||||
<div style="margin-top:8px; font-size:12px; color:var(--red);">{deleteError}</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Channel view — only show if not deleted -->
|
||||
{#if !p.deleted_at}
|
||||
{#if visibleEntries.length > 0}
|
||||
<!-- Mini secondary bar -->
|
||||
<div style="display:flex; align-items:center; justify-content:space-between;
|
||||
margin-bottom:12px; padding:6px 0;">
|
||||
<div class="label">{visibleEntries.length} entries · {p.channel_count} channels</div>
|
||||
<div style="display:flex; border:1px solid var(--border2); border-radius:3px; overflow:hidden;">
|
||||
<button class="btn" type="button"
|
||||
style="border:none; border-radius:0; padding:5px 10px;
|
||||
{view === 'cards' ? 'background:var(--amber-dim); color:var(--amber);' : 'background:transparent; color:var(--text3);'}"
|
||||
on:click={() => view = 'cards'}>
|
||||
Cards
|
||||
</button>
|
||||
<div style="width:1px; background:var(--border2);"></div>
|
||||
<button class="btn" type="button"
|
||||
style="border:none; border-radius:0; padding:5px 10px;
|
||||
{view === 'table' ? 'background:var(--amber-dim); color:var(--amber);' : 'background:transparent; color:var(--text3);'}"
|
||||
on:click={() => view = 'table'}>
|
||||
Table
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if view === 'cards'}
|
||||
<ChannelCardGrid entries={visibleEntries} editable={false} attributes={[]} />
|
||||
{:else}
|
||||
<ChannelTable entries={visibleEntries} editable={false} attributes={[]} />
|
||||
{/if}
|
||||
{:else}
|
||||
<div style="text-align:center; padding:40px; color:var(--text3);">
|
||||
<div style="font-size:13px;">Loading channel data…</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
|
||||
{#if showReportModal}
|
||||
<ReportModal
|
||||
personalityId={p.id}
|
||||
personalityName={p.name}
|
||||
onDone={() => showReportModal = false}
|
||||
onCancel={() => showReportModal = false}
|
||||
/>
|
||||
{/if}
|
||||
12
svelte.config.js
Normal file
12
svelte.config.js
Normal file
@@ -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;
|
||||
40
tailwind.config.cjs
Normal file
40
tailwind.config.cjs
Normal file
@@ -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: []
|
||||
};
|
||||
6
vite.config.js
Normal file
6
vite.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()]
|
||||
});
|
||||
Reference in New Issue
Block a user