initial deployment v1.0

This commit is contained in:
RaineAllDay
2026-03-18 03:06:27 -06:00
commit eaaadd39e4
69 changed files with 10755 additions and 0 deletions

7
.env.example Normal file
View 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
View File

@@ -0,0 +1,8 @@
.env
*.db
*.db-shm
*.db-wal
node_modules/
build/
.svelte-kit/
.DS_Store

41
BINARY_LAYOUT.md Normal file
View 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
View 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: 232 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
View 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 ETCs 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 ETCs 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

Binary file not shown.

2963
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

29
package.json Normal file
View 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
View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};

72
scripts/create-admin.js Normal file
View 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
View 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 (232 chars): " ADMIN_USER
[[ ${#ADMIN_USER} -ge 2 && ${#ADMIN_USER} -le 32 ]] && break
warn "Username must be 232 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 ""

View 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
View 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
View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
View 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
View 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 };

View 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',
];

View 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
View 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
View 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
View 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;
}

View 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);
}
};
}

View 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
View 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}

View 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>

View 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 } };
}

View 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}

View 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)
};
}

View 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>

View 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);
}
};

View 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>

View 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');
}
};

View File

@@ -0,0 +1 @@
<!-- Intentionally empty — logout is handled via form POST action -->

View 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 });
}

View 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 });
}

View 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 });
}

View 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 });
}

View 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 });
}

View 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 });
}

View 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 });
}

View 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 });
}

View 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 });
}

View 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; }
}

View 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 });
}

View 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; }
}

View 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)
}
});
}

View 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 });
}

View 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 });
}

View 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>

View 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>

View 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; }
}

View 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>

View 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; }
}

View 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
View 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
View 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
View File

@@ -0,0 +1,6 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()]
});