merge: bring sequences/auth/admin backend + auth-guard frontend into fix/library-and-signal-indicator
This commit is contained in:
commit
1f31d1037d
21 changed files with 4300 additions and 691 deletions
2246
docs/superpowers/plans/2026-05-18-nle-editor.md
Normal file
2246
docs/superpowers/plans/2026-05-18-nle-editor.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,356 @@
|
|||
# Wild Dragon MAM — Editor, Growing File Preview & Feature Expansion
|
||||
**Date:** 2026-05-18
|
||||
**Status:** APPROVED — ready for implementation planning
|
||||
|
||||
---
|
||||
|
||||
## Resolved Decisions
|
||||
|
||||
| Question | Decision |
|
||||
|----------|----------|
|
||||
| Save strategy | **Auto-save** (debounced 2s after any change) |
|
||||
| Sequences per project | **Multiple named sequences** (like Premiere's project panel) |
|
||||
| HLS temp disk | **3 TB available** — no constraint |
|
||||
| Primary frame rate | **59.94 fps** |
|
||||
| Program monitor fidelity | **Brief gap at clip boundaries is acceptable for v1** |
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Three sequenced phases:
|
||||
|
||||
1. **NLE Editor** (`editor.html`) — Premiere-style timeline editor with razor blade, in/out marking, and EDL export
|
||||
2. **Growing File Workflow** — HLS live preview during SDI/SRT/RTMP capture, with rewind support
|
||||
3. **Feature Additions** — Subclips, improved player, bulk ops, waveform, timecoded comments, smart bins, metadata templates
|
||||
|
||||
---
|
||||
|
||||
## 1. NLE Editor
|
||||
|
||||
### 1.1 Layout
|
||||
|
||||
A new page `editor.html`. `player.html` is kept as the lightweight browse/metadata view; the editor is the full-screen creative environment.
|
||||
|
||||
```
|
||||
┌─ SOURCE MONITOR ──────┬─ PROGRAM MONITOR ──────┐ ← 40vh
|
||||
│ [video preview] │ [video preview] │
|
||||
│ TC: 00:00:00;00 │ TC: 00:00:00;00 │
|
||||
│ [────scrub bar────] │ [────scrub bar────] │
|
||||
│ [IN] [OUT] [Insert] │ [J] [K] [L] [⏩] │
|
||||
├─ MEDIA PANEL ─────────┴─ TIMELINE ─────────────┤ ← 60vh
|
||||
│ [sequence picker] │ [V] [C] [H] [zoom] │
|
||||
│ [bin tree] │ ruler: 00:00 00:05 … │
|
||||
│ [asset list] │ V1 ░░░░[clip A]░░░░ │
|
||||
│ │ V2 │
|
||||
│ │ A1 ░░░░[clip A]░░░░ │
|
||||
│ │ A2 │
|
||||
└───────────────────────┴─────────────────────────┘
|
||||
```
|
||||
|
||||
Accessed from the library via an "Open in Editor" action on each asset card. Opens `editor.html?project=<id>&asset=<id>` — loads the asset into the source monitor and opens the project's most-recent sequence (or creates one named "Sequence 1" if none exists).
|
||||
|
||||
The sidebar nav gains an **Editor** link (between Library and Ingest).
|
||||
|
||||
### 1.2 Source Monitor
|
||||
|
||||
- Displays the currently loaded clip (double-click in media panel, or via `?asset=` param)
|
||||
- Native `<video>` element with a **custom** transport bar: scrub slider, current-time / duration label, play/pause button
|
||||
- **Mark In (`I`):** stores `sourceIn` as seconds; shown as left handle on the scrub bar
|
||||
- **Mark Out (`O`):** stores `sourceOut` as seconds; shown as right handle on the scrub bar
|
||||
- In/out range highlighted on scrub bar (accent color tint)
|
||||
- **Insert:** drops marked range at timeline playhead, shifts downstream clips right
|
||||
- **Overwrite:** drops marked range at timeline playhead, overwrites what's there
|
||||
- No marks set → uses full clip duration
|
||||
|
||||
### 1.3 Program Monitor
|
||||
|
||||
- Plays the timeline from the current playhead position
|
||||
- **Virtual playback:** single `<video>` element. On each clip start, set `src` = that clip's signed proxy URL and `currentTime` = `sourceIn`. When `currentTime` reaches `sourceOut`, load the next clip. Brief load gap at clip boundaries is acceptable for v1.
|
||||
- Timecode display: `HH:MM:SS;FF` at **59.94 fps** (drop-frame notation, semicolon separator)
|
||||
- Transport shortcuts: `Space` (play/pause), `J` (reverse), `K` (stop), `L` (forward), `←`/`→` (± 1 frame at 59.94), `Home` (jump to start)
|
||||
|
||||
### 1.4 Timeline
|
||||
|
||||
**Ruler:** Timecode-marked horizontal ruler. Default scale: 100px/s. Zoom: `Ctrl + scroll wheel` or zoom slider (range: 20px/s → 500px/s).
|
||||
|
||||
**Tracks:** V1, V2 (video), A1, A2 (audio). Each track row: 48px tall. Track header (40px wide, left): track label, lock toggle.
|
||||
|
||||
**Clips:** Absolutely positioned `<div>` elements within track rows.
|
||||
- `left` = `timelineIn_seconds × scale`
|
||||
- `width` = `(timelineOut - timelineIn)_seconds × scale`
|
||||
- Shows: clip name (truncated), source TC range
|
||||
- If width > 120px: asset thumbnail centered in clip body
|
||||
|
||||
**Playhead:** Red vertical line spanning all tracks. Draggable. Clicking the ruler jumps playhead.
|
||||
|
||||
**Tools:**
|
||||
|
||||
| Tool | Key | Behavior |
|
||||
|------|-----|----------|
|
||||
| Select | `V` | Click to select (accent border). Drag body to move horizontally. Drag left/right edge to trim source in/out. |
|
||||
| Razor | `C` | Click on a clip → splits into two clips at that exact frame. |
|
||||
| Hand | `H` | Click-drag to pan timeline horizontally. |
|
||||
|
||||
**Editing:**
|
||||
- `Delete` / `Backspace`: remove selected clip(s)
|
||||
- `Ctrl+Z` / `Ctrl+Shift+Z`: undo / redo (local history stack, max 50 steps)
|
||||
- Clips cannot overlap within the same track (enforced on move/razor)
|
||||
|
||||
**Auto-save:** Debounced 2s after any timeline change. Visual indicator: subtle "Saving…" / "Saved" text in the timeline toolbar.
|
||||
|
||||
### 1.5 Data Model
|
||||
|
||||
**New migration: `schema_patch_editor.sql`**
|
||||
|
||||
```sql
|
||||
CREATE TABLE sequences (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
project_id UUID NOT NULL REFERENCES projects ON DELETE CASCADE,
|
||||
name TEXT NOT NULL DEFAULT 'Sequence 1',
|
||||
frame_rate NUMERIC(6,3) NOT NULL DEFAULT 59.94,
|
||||
width INTEGER NOT NULL DEFAULT 1920,
|
||||
height INTEGER NOT NULL DEFAULT 1080,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_sequences_project_id ON sequences(project_id);
|
||||
|
||||
CREATE TABLE sequence_clips (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
sequence_id UUID NOT NULL REFERENCES sequences ON DELETE CASCADE,
|
||||
asset_id UUID NOT NULL REFERENCES assets ON DELETE CASCADE,
|
||||
track INTEGER NOT NULL DEFAULT 0,
|
||||
-- track encoding: 0=V1, 1=V2, 100=A1, 101=A2
|
||||
timeline_in_frames BIGINT NOT NULL,
|
||||
timeline_out_frames BIGINT NOT NULL,
|
||||
source_in_frames BIGINT NOT NULL DEFAULT 0,
|
||||
source_out_frames BIGINT NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_sequence_clips_sequence_id ON sequence_clips(sequence_id);
|
||||
```
|
||||
|
||||
**Frame math:** All frame counts use **59.94 fps** (= 60000/1001). Timecode display uses SMPTE drop-frame (`;` separator). Conversion helpers:
|
||||
- `framesToSeconds(f)` = `f / 59.94`
|
||||
- `secondsToFrames(s)` = `Math.round(s * 59.94)`
|
||||
- Drop-frame TC calculation uses standard SMPTE DF algorithm
|
||||
|
||||
### 1.6 API Routes
|
||||
|
||||
New file: `services/mam-api/src/routes/sequences.js`
|
||||
|
||||
| Method | Path | Body / Params | Notes |
|
||||
|--------|------|--------------|-------|
|
||||
| GET | `/api/v1/sequences` | `?project_id=` | List sequences, ordered by `updated_at DESC` |
|
||||
| POST | `/api/v1/sequences` | `{ project_id, name, frame_rate?, width?, height? }` | Create |
|
||||
| GET | `/api/v1/sequences/:id` | — | Sequence + all clips joined with asset (`display_name`, `fps`, `duration_ms`, `proxy_s3_key`, `thumbnail_s3_key`) |
|
||||
| PUT | `/api/v1/sequences/:id` | `{ name?, frame_rate?, width?, height? }` | Update metadata |
|
||||
| DELETE | `/api/v1/sequences/:id` | — | Cascade deletes clips |
|
||||
| PUT | `/api/v1/sequences/:id/clips` | `[ { asset_id, track, timeline_in_frames, timeline_out_frames, source_in_frames, source_out_frames } ]` | **Full replace** — deletes existing clips, inserts new array in one transaction |
|
||||
| POST | `/api/v1/sequences/:id/export/edl` | — | Returns CMX3600 EDL as `text/plain; charset=utf-8`, `Content-Disposition: attachment` |
|
||||
|
||||
### 1.7 EDL Export
|
||||
|
||||
`generateEDL(sequenceName, clips, fps)` function produces CMX3600. Timecode math reuses the logic from `worker/src/edl/parser.js` (copied into a shared util or duplicated in the API route).
|
||||
|
||||
```
|
||||
TITLE: My Sequence
|
||||
|
||||
001 clip_filename V C 00:00:00;00 00:00:05;00 00:00:00;00 00:00:05;00
|
||||
002 other_clip V C 00:00:02;00 00:00:08;00 00:00:05;00 00:00:11;00
|
||||
```
|
||||
|
||||
Conforms via the existing `conform.js` BullMQ worker unchanged.
|
||||
|
||||
### 1.8 `api.js` Additions
|
||||
|
||||
```js
|
||||
getSequences(projectId)
|
||||
createSequence(data)
|
||||
getSequence(sequenceId)
|
||||
updateSequence(sequenceId, data)
|
||||
deleteSequence(sequenceId)
|
||||
syncSequenceClips(sequenceId, clipsArray)
|
||||
exportSequenceEDL(sequenceId) // triggers file download
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Growing File Workflow
|
||||
|
||||
### 2.1 Problem
|
||||
|
||||
During SDI capture, proxy is piped to S3 via multipart upload — invisible until `CompleteMultipartUpload`. No live preview is possible. For SRT/RTMP, no proxy exists at all until the BullMQ worker runs post-stop.
|
||||
|
||||
### 2.2 Solution: HLS Segments During Capture
|
||||
|
||||
Write proxy as HLS segments to local disk during recording. Serve them live from the capture service. On stop: stitch segments → single MP4 → upload to S3 as proxy.
|
||||
|
||||
**Why HLS:** Growing playlists are the browser-native live video mechanism. Accumulated segments give free rewind. `hls.js` is a small CDN-loadable polyfill for Chrome/Firefox. FFmpeg's `hls` muxer is proven in production.
|
||||
|
||||
### 2.3 FFmpeg Args Change
|
||||
|
||||
**SDI (second FFmpeg process — proxy process):**
|
||||
```bash
|
||||
# Before: fragmented MP4 → pipe → S3
|
||||
ffmpeg [decklink] -c:v libx264 -preset fast -b:v 10M -c:a aac -b:a 192k \
|
||||
-movflags +frag_keyframe+empty_moov -f mp4 pipe:1
|
||||
|
||||
# After: HLS segments → local dir
|
||||
ffmpeg [decklink] -c:v libx264 -preset fast -b:v 10M -c:a aac -b:a 192k \
|
||||
-hls_time 2 -hls_list_size 0 -hls_flags append_list \
|
||||
-hls_segment_filename '$HLS_DIR/<sessionId>/seg%05d.ts' \
|
||||
$HLS_DIR/<sessionId>/index.m3u8
|
||||
```
|
||||
|
||||
**SRT/RTMP (single FFmpeg process — two output pads):**
|
||||
```bash
|
||||
ffmpeg [srt/rtmp input] \
|
||||
-c:v prores_ks -profile:v 3 -c:a pcm_s24le \
|
||||
-movflags +frag_keyframe+empty_moov -f mov pipe:1 \
|
||||
-c:v libx264 -preset fast -b:v 10M -c:a aac -b:a 192k \
|
||||
-hls_time 2 -hls_list_size 0 -hls_flags append_list \
|
||||
-hls_segment_filename '$HLS_DIR/<sessionId>/seg%05d.ts' \
|
||||
$HLS_DIR/<sessionId>/index.m3u8
|
||||
```
|
||||
|
||||
### 2.4 Capture Service Changes
|
||||
|
||||
**`capture-manager.js`:**
|
||||
- New env var: `HLS_SESSION_DIR` (default: `/tmp/wd-hls`). 3 TB disk — no constraint.
|
||||
- `start()`: `mkdir -p $HLS_SESSION_DIR/<sessionId>/`, spawn FFmpeg with HLS output. Add `liveUrl: /capture/live/<sessionId>/index.m3u8` to session state.
|
||||
- `stop()`:
|
||||
1. `SIGINT` FFmpeg (existing)
|
||||
2. Wait for process exit (FFmpeg writes final segment + closes playlist before exiting)
|
||||
3. `ffmpeg -i $HLS_DIR/<sessionId>/index.m3u8 -c copy <tmp>.mp4` (stitch)
|
||||
4. Upload stitched MP4 to S3 as proxy key (existing `uploadToS3` helper)
|
||||
5. `rm -rf $HLS_DIR/<sessionId>/`
|
||||
- Startup: scan `$HLS_SESSION_DIR/` and delete dirs older than 24h
|
||||
|
||||
**New capture routes:**
|
||||
```
|
||||
GET /capture/live/:sessionId/index.m3u8 → serve HLS playlist file
|
||||
GET /capture/live/:sessionId/:file → serve .ts segment files
|
||||
```
|
||||
Both routes: check session exists (active or recently stopped with dir still present), stream file from disk with correct `Content-Type` (`application/vnd.apple.mpegurl` / `video/mp2t`).
|
||||
|
||||
**Updated `/capture/status` response:**
|
||||
```json
|
||||
{
|
||||
"recording": true,
|
||||
"sessionId": "abc123",
|
||||
"liveUrl": "/capture/live/abc123/index.m3u8",
|
||||
"startedAt": "...",
|
||||
"duration": 42,
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**nginx:** Add location block for `/capture/live/` → proxy_pass to capture service (same pattern as existing `/capture/` block).
|
||||
|
||||
### 2.5 Live Preview in `capture.html`
|
||||
|
||||
When status poll returns `recording: true` with `liveUrl`:
|
||||
1. Show collapsible **Live Preview** panel beneath capture controls
|
||||
2. Load hls.js from CDN: `cdnjs.cloudflare.com/ajax/libs/hls.js/1.5.15/hls.min.js`
|
||||
3. `new Hls()` → `hls.loadSource(status.liveUrl)` → `hls.attachMedia(videoEl)`
|
||||
4. `videoEl.play()` on `MANIFEST_PARSED` (plays at live edge)
|
||||
5. **⏮ Rewind** button: `videoEl.currentTime = 0`
|
||||
6. Elapsed time counter from `status.startedAt`
|
||||
|
||||
On recording stop: `hls.destroy()`, hide preview panel. Asset appears in library after proxy upload + thumbnail job complete.
|
||||
|
||||
---
|
||||
|
||||
## 3. Feature Additions (Prioritized)
|
||||
|
||||
### P1 — Improved Player (`player.html` rebuild)
|
||||
- Migrate from old CSS variables (`--color-bg-tertiary`) to current design tokens (`--bg-panel`, etc.)
|
||||
- Replace browser default controls with custom transport bar: scrub slider, timecode display `HH:MM:SS;FF` at 59.94 fps, frame-step buttons, J/K/L shortcuts
|
||||
- Inline rename: click `display_name` to edit in place → auto-save
|
||||
- "Open in Editor" button → `editor.html?asset=<id>`
|
||||
|
||||
### P2 — Subclips
|
||||
- Player shows In/Out markers → "Create Subclip" button
|
||||
- `POST /api/v1/assets` with `{ parent_asset_id, subclip_in_ms, subclip_out_ms, display_name }`
|
||||
- New columns: `parent_asset_id UUID REFERENCES assets`, `subclip_in_ms BIGINT`, `subclip_out_ms BIGINT`
|
||||
- Library shows subclip cards with ✂ badge; player pre-seeks to `subclip_in_ms`
|
||||
- Subclips use parent's proxy S3 key — no re-transcode
|
||||
|
||||
### P3 — Multi-select & Bulk Ops
|
||||
- Shift-click or checkbox (visible on hover) to select multiple asset cards
|
||||
- Floating action bar: **Move to bin | Add tags | Delete**
|
||||
- Move to bin: slide panel with bin tree, `PATCH /assets/:id { bin_id }` for each
|
||||
- Add tags: appends to all selected assets
|
||||
- Bulk delete: confirm modal → soft-delete
|
||||
|
||||
### P4 — Waveform Display in Editor
|
||||
- In proxy worker (`proxy.js`): after transcode, run FFmpeg `astats` filter to generate per-second peak arrays → store as `waveforms/<assetId>.json` in S3
|
||||
- New `waveform_s3_key TEXT` column on `assets`
|
||||
- Editor timeline: fetch waveform JSON for audio track clips, render as SVG polyline within clip block
|
||||
|
||||
### P5 — Timecoded Comments
|
||||
```sql
|
||||
CREATE TABLE asset_comments (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
asset_id UUID NOT NULL REFERENCES assets ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users,
|
||||
timecode_seconds NUMERIC(10,3),
|
||||
body TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
Player sidebar: "Comments" tab with "Post at current TC" button. Clickable entries seek video to that timecode.
|
||||
|
||||
### P6 — Smart Bins
|
||||
```sql
|
||||
ALTER TABLE bins ADD COLUMN is_smart BOOLEAN DEFAULT false;
|
||||
ALTER TABLE bins ADD COLUMN smart_query JSONB;
|
||||
-- smart_query shape: { "tags": ["interview"], "media_type": "video", "created_after": "2026-01-01" }
|
||||
```
|
||||
Smart bin assets: dynamically queried from `assets` table. Shows ✦ icon in bin tree.
|
||||
|
||||
### P7 — Metadata Templates
|
||||
```sql
|
||||
CREATE TABLE project_metadata_fields (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
project_id UUID NOT NULL REFERENCES projects ON DELETE CASCADE,
|
||||
field_key TEXT NOT NULL,
|
||||
label TEXT NOT NULL,
|
||||
field_type TEXT NOT NULL DEFAULT 'text',
|
||||
options JSONB,
|
||||
required BOOLEAN DEFAULT false,
|
||||
sort_order INTEGER DEFAULT 0
|
||||
);
|
||||
ALTER TABLE assets ADD COLUMN metadata JSONB DEFAULT '{}';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Sequencing
|
||||
|
||||
### Phase 1 — Editor
|
||||
1. DB migration (`schema_patch_editor.sql`)
|
||||
2. API (`sequences.js` route — CRUD + clip sync + EDL export)
|
||||
3. `api.js` — sequence helpers
|
||||
4. `editor.html` — 4-panel shell + CSS (sidebar nav update)
|
||||
5. Timeline engine — ruler, playhead, track rows, clip rendering
|
||||
6. Select tool — click-select, drag-move, drag-edge trim
|
||||
7. Razor tool — click-to-split
|
||||
8. Source monitor — video + transport + in/out marking + Insert/Overwrite
|
||||
9. Program monitor — virtual playback + 59.94 drop-frame timecode
|
||||
10. Auto-save (debounced 2s) + sequence picker in media panel
|
||||
11. Library "Open in Editor" action + sidebar link
|
||||
|
||||
### Phase 2 — Growing File
|
||||
1. Capture service: HLS output, `$HLS_SESSION_DIR` env var
|
||||
2. Capture service: `/capture/live/:sessionId/` routes + nginx config
|
||||
3. Capture service: on-stop stitch + S3 upload + cleanup
|
||||
4. `capture.html`: live preview panel (hls.js), rewind button
|
||||
|
||||
### Phase 3 — Feature Additions
|
||||
P1 → P2 → P3 → P4 → P5 → P6 → P7
|
||||
86
services/mam-api/src/db/schema_patch_editor.sql
Normal file
86
services/mam-api/src/db/schema_patch_editor.sql
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
-- Wild Dragon MAM – Editor sequences schema patch
|
||||
-- Run with: psql $DATABASE_URL -f schema_patch_editor.sql
|
||||
|
||||
-- Named timelines within a project (multiple per project, like Premiere)
|
||||
CREATE TABLE IF NOT EXISTS sequences (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
project_id UUID NOT NULL REFERENCES projects ON DELETE CASCADE,
|
||||
name TEXT NOT NULL DEFAULT 'Sequence 1',
|
||||
frame_rate NUMERIC(6,3) NOT NULL DEFAULT 59.94,
|
||||
width INTEGER NOT NULL DEFAULT 1920,
|
||||
height INTEGER NOT NULL DEFAULT 1080,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
CONSTRAINT uq_sequences_project_name UNIQUE (project_id, name)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sequences_project_id ON sequences(project_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_sequences_updated_at ON sequences(updated_at DESC);
|
||||
|
||||
-- Clips placed on a sequence timeline
|
||||
CREATE TABLE IF NOT EXISTS sequence_clips (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
sequence_id UUID NOT NULL REFERENCES sequences ON DELETE CASCADE,
|
||||
asset_id UUID NOT NULL REFERENCES assets ON DELETE CASCADE,
|
||||
track INTEGER NOT NULL DEFAULT 0 CHECK (track >= 0),
|
||||
-- track encoding: 0=V1, 1=V2, 100=A1, 101=A2
|
||||
-- Open-ended CHECK (track >= 0) used instead of an enumerated list so that
|
||||
-- additional tracks can be added in the future without a schema migration.
|
||||
timeline_in_frames BIGINT NOT NULL,
|
||||
timeline_out_frames BIGINT NOT NULL,
|
||||
source_in_frames BIGINT NOT NULL DEFAULT 0,
|
||||
source_out_frames BIGINT NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
CONSTRAINT chk_timeline_range CHECK (timeline_out_frames > timeline_in_frames),
|
||||
CONSTRAINT chk_source_range CHECK (source_out_frames > source_in_frames)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sequence_clips_sequence_id ON sequence_clips(sequence_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_sequence_clips_track_position ON sequence_clips(sequence_id, track, timeline_in_frames);
|
||||
CREATE INDEX IF NOT EXISTS idx_sequence_clips_asset_id ON sequence_clips(asset_id);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Idempotent ALTER TABLE block — applies the new constraints and index to
|
||||
-- tables that were already created by an earlier run of this file.
|
||||
-- Uses DO blocks because PostgreSQL does not support ADD CONSTRAINT IF NOT EXISTS.
|
||||
-- Safe to re-run.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'uq_sequences_project_name' AND conrelid = 'sequences'::regclass
|
||||
) THEN
|
||||
ALTER TABLE sequences ADD CONSTRAINT uq_sequences_project_name UNIQUE (project_id, name);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'chk_timeline_range' AND conrelid = 'sequence_clips'::regclass
|
||||
) THEN
|
||||
ALTER TABLE sequence_clips ADD CONSTRAINT chk_timeline_range CHECK (timeline_out_frames > timeline_in_frames);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'chk_source_range' AND conrelid = 'sequence_clips'::regclass
|
||||
) THEN
|
||||
ALTER TABLE sequence_clips ADD CONSTRAINT chk_source_range CHECK (source_out_frames > source_in_frames);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'chk_track_valid' AND conrelid = 'sequence_clips'::regclass
|
||||
) THEN
|
||||
ALTER TABLE sequence_clips ADD CONSTRAINT chk_track_valid CHECK (track >= 0);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sequence_clips_asset_id ON sequence_clips(asset_id);
|
||||
36
services/mam-api/src/db/schema_patch_groups_tokens.sql
Normal file
36
services/mam-api/src/db/schema_patch_groups_tokens.sql
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
-- Wild Dragon MAM – Groups & API Tokens schema patch
|
||||
-- Run with: psql $DATABASE_URL -f schema_patch_groups_tokens.sql
|
||||
|
||||
-- User groups
|
||||
CREATE TABLE IF NOT EXISTS groups (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
name TEXT UNIQUE NOT NULL,
|
||||
description TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- User ↔ group memberships
|
||||
CREATE TABLE IF NOT EXISTS user_groups (
|
||||
user_id UUID NOT NULL REFERENCES users ON DELETE CASCADE,
|
||||
group_id UUID NOT NULL REFERENCES groups ON DELETE CASCADE,
|
||||
PRIMARY KEY (user_id, group_id)
|
||||
);
|
||||
|
||||
-- Personal API tokens (Bearer auth alternative to session cookies)
|
||||
-- token_hash : SHA-256(raw_token) stored as hex
|
||||
-- token_prefix: first 8 chars of raw token for display only
|
||||
CREATE TABLE IF NOT EXISTS api_tokens (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id UUID NOT NULL REFERENCES users ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
token_prefix TEXT NOT NULL,
|
||||
last_used_at TIMESTAMPTZ,
|
||||
expires_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_api_tokens_user_id ON api_tokens(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON api_tokens(token_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_groups_user ON user_groups(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_groups_group ON user_groups(group_id);
|
||||
|
|
@ -17,6 +17,10 @@ import uploadRouter from './routes/upload.js';
|
|||
import recordersRouter from './routes/recorders.js';
|
||||
import settingsRouter from './routes/settings.js';
|
||||
import amppRouter from './routes/ampp.js';
|
||||
import usersRouter from './routes/users.js';
|
||||
import groupsRouter from './routes/groups.js';
|
||||
import tokensRouter from './routes/tokens.js';
|
||||
import sequencesRouter from './routes/sequences.js';
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
|
@ -63,6 +67,10 @@ app.use('/api/v1/upload', uploadRouter);
|
|||
app.use('/api/v1/recorders', recordersRouter);
|
||||
app.use('/api/v1/settings', settingsRouter);
|
||||
app.use('/api/v1/ampp', amppRouter);
|
||||
app.use('/api/v1/users', usersRouter);
|
||||
app.use('/api/v1/groups', groupsRouter);
|
||||
app.use('/api/v1/tokens', tokensRouter);
|
||||
app.use('/api/v1/sequences', sequencesRouter);
|
||||
|
||||
// ── Error handler ─────────────────────────────────────────────────────────────
|
||||
app.use(errorHandler);
|
||||
|
|
|
|||
|
|
@ -2,17 +2,62 @@
|
|||
* Authentication middleware.
|
||||
*
|
||||
* When AUTH_ENABLED=true in the environment, every protected route requires
|
||||
* an active session (set by POST /api/v1/auth/login).
|
||||
* either:
|
||||
* - An active session (set by POST /api/v1/auth/login), or
|
||||
* - A valid Bearer token in Authorization header (set by POST /api/v1/tokens)
|
||||
*
|
||||
* When AUTH_ENABLED is unset or any other value, the middleware is a no-op
|
||||
* so the stack can be deployed and tested without setting up users first.
|
||||
* Set AUTH_ENABLED=true in production after running POST /api/v1/auth/setup
|
||||
* to create the first admin account.
|
||||
* When AUTH_ENABLED is unset or any other value, all middleware is a no-op so
|
||||
* the stack can be run without user accounts during development.
|
||||
*/
|
||||
export const requireAuth = (req, res, next) => {
|
||||
import crypto from 'crypto';
|
||||
import pool from '../db/pool.js';
|
||||
|
||||
export const requireAuth = async (req, res, next) => {
|
||||
if (process.env.AUTH_ENABLED !== 'true') return next();
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
next();
|
||||
|
||||
// ── Session-based auth ────────────────────────────────────────
|
||||
if (req.session?.userId) {
|
||||
req.user = {
|
||||
id: req.session.userId,
|
||||
username: req.session.username,
|
||||
role: req.session.role,
|
||||
};
|
||||
return next();
|
||||
}
|
||||
|
||||
// ── Bearer token auth ─────────────────────────────────────────
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader?.startsWith('Bearer ')) {
|
||||
const raw = authHeader.slice(7).trim();
|
||||
const hash = crypto.createHash('sha256').update(raw).digest('hex');
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT t.user_id AS id, u.username, u.role
|
||||
FROM api_tokens t
|
||||
JOIN users u ON u.id = t.user_id
|
||||
WHERE t.token_hash = $1
|
||||
AND (t.expires_at IS NULL OR t.expires_at > NOW())`,
|
||||
[hash]
|
||||
);
|
||||
if (rows.length > 0) {
|
||||
req.user = rows[0];
|
||||
// Fire-and-forget last_used_at update
|
||||
pool.query(
|
||||
'UPDATE api_tokens SET last_used_at = NOW() WHERE token_hash = $1',
|
||||
[hash]
|
||||
).catch(() => {});
|
||||
return next();
|
||||
}
|
||||
} catch (err) {
|
||||
return next(err);
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
};
|
||||
|
||||
export const requireAdmin = (req, res, next) => {
|
||||
if (process.env.AUTH_ENABLED !== 'true') return next();
|
||||
if (req.user?.role === 'admin') return next();
|
||||
return res.status(403).json({ error: 'Admin access required' });
|
||||
};
|
||||
|
|
|
|||
|
|
@ -74,6 +74,12 @@ router.post('/logout', (req, res, next) => {
|
|||
// GET /me
|
||||
// ---------------------------------------------------------------------------
|
||||
router.get('/me', async (req, res) => {
|
||||
// When auth is disabled return a synthetic guest/admin user so the frontend
|
||||
// auth-guard never receives a 401 and never redirects to login.html.
|
||||
if (process.env.AUTH_ENABLED !== 'true') {
|
||||
return res.json({ id: null, username: 'admin', display_name: 'Admin', role: 'admin' });
|
||||
}
|
||||
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Not authenticated' });
|
||||
}
|
||||
|
|
|
|||
114
services/mam-api/src/routes/groups.js
Normal file
114
services/mam-api/src/routes/groups.js
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
/**
|
||||
* Group management routes (admin-only when AUTH_ENABLED=true)
|
||||
*
|
||||
* GET /api/v1/groups — list all groups
|
||||
* POST /api/v1/groups — create group
|
||||
* PATCH /api/v1/groups/:id — update group
|
||||
* DELETE /api/v1/groups/:id — delete group
|
||||
* GET /api/v1/groups/:id/members — list members
|
||||
* POST /api/v1/groups/:id/members — add member { user_id }
|
||||
* DELETE /api/v1/groups/:id/members/:uid — remove member
|
||||
*/
|
||||
import express from 'express';
|
||||
import pool from '../db/pool.js';
|
||||
import { requireAuth, requireAdmin } from '../middleware/auth.js';
|
||||
|
||||
const router = express.Router();
|
||||
router.use(requireAuth, requireAdmin);
|
||||
|
||||
// ── List ──────────────────────────────────────────────────────
|
||||
router.get('/', async (_req, res, next) => {
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT g.id, g.name, g.description, g.created_at,
|
||||
COUNT(ug.user_id)::int AS member_count
|
||||
FROM groups g
|
||||
LEFT JOIN user_groups ug ON ug.group_id = g.id
|
||||
GROUP BY g.id
|
||||
ORDER BY g.name`
|
||||
);
|
||||
res.json(rows);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// ── Create ────────────────────────────────────────────────────
|
||||
router.post('/', async (req, res, next) => {
|
||||
try {
|
||||
const { name, description } = req.body;
|
||||
if (!name) return res.status(400).json({ error: 'name required' });
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO groups (name, description) VALUES ($1, $2) RETURNING *`,
|
||||
[name.trim(), description || null]
|
||||
);
|
||||
res.status(201).json(rows[0]);
|
||||
} catch (err) {
|
||||
if (err.code === '23505') return res.status(409).json({ error: 'Group name already exists' });
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Update ────────────────────────────────────────────────────
|
||||
router.patch('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const { name, description } = req.body;
|
||||
const sets = []; const vals = [];
|
||||
if (name !== undefined) { sets.push(`name = $${sets.length + 1}`); vals.push(name); }
|
||||
if (description !== undefined) { sets.push(`description = $${sets.length + 1}`); vals.push(description); }
|
||||
if (!sets.length) return res.status(400).json({ error: 'Nothing to update' });
|
||||
vals.push(req.params.id);
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE groups SET ${sets.join(', ')} WHERE id = $${vals.length} RETURNING *`,
|
||||
vals
|
||||
);
|
||||
if (!rows.length) return res.status(404).json({ error: 'Group not found' });
|
||||
res.json(rows[0]);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// ── Delete ────────────────────────────────────────────────────
|
||||
router.delete('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const { rowCount } = await pool.query('DELETE FROM groups WHERE id = $1', [req.params.id]);
|
||||
if (!rowCount) return res.status(404).json({ error: 'Group not found' });
|
||||
res.json({ message: 'Group deleted' });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// ── Members ───────────────────────────────────────────────────
|
||||
router.get('/:id/members', async (req, res, next) => {
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT u.id, u.username, u.display_name, u.role
|
||||
FROM user_groups ug
|
||||
JOIN users u ON u.id = ug.user_id
|
||||
WHERE ug.group_id = $1
|
||||
ORDER BY u.username`,
|
||||
[req.params.id]
|
||||
);
|
||||
res.json(rows);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
router.post('/:id/members', async (req, res, next) => {
|
||||
try {
|
||||
const { user_id } = req.body;
|
||||
if (!user_id) return res.status(400).json({ error: 'user_id required' });
|
||||
await pool.query(
|
||||
`INSERT INTO user_groups (user_id, group_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
|
||||
[user_id, req.params.id]
|
||||
);
|
||||
res.status(201).json({ message: 'Member added' });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
router.delete('/:id/members/:uid', async (req, res, next) => {
|
||||
try {
|
||||
await pool.query(
|
||||
`DELETE FROM user_groups WHERE group_id = $1 AND user_id = $2`,
|
||||
[req.params.id, req.params.uid]
|
||||
);
|
||||
res.json({ message: 'Member removed' });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
export default router;
|
||||
233
services/mam-api/src/routes/sequences.js
Normal file
233
services/mam-api/src/routes/sequences.js
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
// services/mam-api/src/routes/sequences.js
|
||||
import express from 'express';
|
||||
import pool from '../db/pool.js';
|
||||
import { getSignedUrlForObject } from '../s3/client.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
|
||||
const router = express.Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
// ── 59.94 DF timecode helpers (for EDL export) ────────────────────────────────
|
||||
const NOM = 60; // nominal integer fps
|
||||
const DROP = 4; // frames dropped per minute (except every 10th)
|
||||
const FRAMES_PER_MIN = NOM * 60 - DROP; // 3596
|
||||
const FRAMES_PER_10MIN = FRAMES_PER_MIN * 10 + DROP; // 35964
|
||||
const FRAMES_PER_HOUR = FRAMES_PER_10MIN * 6; // 215784
|
||||
|
||||
function pad2(n) { return String(Math.floor(n)).padStart(2, '0'); }
|
||||
|
||||
function framesToTC(totalFrames) {
|
||||
const fc = Math.max(0, Math.round(totalFrames));
|
||||
const h = Math.floor(fc / FRAMES_PER_HOUR);
|
||||
let rem = fc % FRAMES_PER_HOUR;
|
||||
const tm = Math.floor(rem / FRAMES_PER_10MIN);
|
||||
rem = rem % FRAMES_PER_10MIN;
|
||||
let m = 0;
|
||||
if (rem >= DROP) {
|
||||
m = Math.floor((rem - DROP) / FRAMES_PER_MIN) + 1;
|
||||
rem = (rem - DROP) % FRAMES_PER_MIN;
|
||||
}
|
||||
const M = tm * 10 + m;
|
||||
const s = Math.floor(rem / NOM);
|
||||
const ff = rem % NOM;
|
||||
return `${pad2(h)}:${pad2(M)}:${pad2(s)};${pad2(ff)}`;
|
||||
}
|
||||
|
||||
function generateEDL(seqName, clips) {
|
||||
const lines = [`TITLE: ${seqName}`, ''];
|
||||
clips.forEach((c, i) => {
|
||||
const num = String(i + 1).padStart(3, '0');
|
||||
const reel = (c.filename || 'UNKNOWN')
|
||||
.replace(/\.[^.]+$/, '') // strip extension
|
||||
.replace(/[^A-Za-z0-9_]/g, '_')
|
||||
.toUpperCase()
|
||||
.substring(0, 32)
|
||||
.padEnd(8);
|
||||
const srcIn = framesToTC(c.source_in_frames);
|
||||
const srcOut = framesToTC(c.source_out_frames);
|
||||
const recIn = framesToTC(c.timeline_in_frames);
|
||||
const recOut = framesToTC(c.timeline_out_frames);
|
||||
lines.push(`${num} ${reel} V C ${srcIn} ${srcOut} ${recIn} ${recOut}`);
|
||||
});
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// ── GET / – list sequences for a project ─────────────────────────────────────
|
||||
router.get('/', async (req, res, next) => {
|
||||
try {
|
||||
const { project_id } = req.query;
|
||||
if (!project_id) return res.status(400).json({ error: 'project_id is required' });
|
||||
const r = await pool.query(
|
||||
`SELECT * FROM sequences WHERE project_id = $1 ORDER BY updated_at DESC`,
|
||||
[project_id]
|
||||
);
|
||||
res.json(r.rows);
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// ── POST / – create sequence ──────────────────────────────────────────────────
|
||||
router.post('/', async (req, res, next) => {
|
||||
try {
|
||||
const {
|
||||
project_id,
|
||||
name = 'Sequence 1',
|
||||
frame_rate = 59.94,
|
||||
width = 1920,
|
||||
height = 1080,
|
||||
} = req.body;
|
||||
if (!project_id) return res.status(400).json({ error: 'project_id is required' });
|
||||
const r = await pool.query(
|
||||
`INSERT INTO sequences (project_id, name, frame_rate, width, height)
|
||||
VALUES ($1, $2, $3, $4, $5) RETURNING *`,
|
||||
[project_id, name, frame_rate, width, height]
|
||||
);
|
||||
res.status(201).json(r.rows[0]);
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// ── GET /:id – sequence + all clips joined with asset data ───────────────────
|
||||
router.get('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const seqR = await pool.query(
|
||||
`SELECT * FROM sequences WHERE id = $1`,
|
||||
[req.params.id]
|
||||
);
|
||||
if (!seqR.rows.length) return res.status(404).json({ error: 'Sequence not found' });
|
||||
|
||||
const clipsR = await pool.query(
|
||||
`SELECT sc.*,
|
||||
a.display_name, a.filename, a.fps, a.duration_ms,
|
||||
a.proxy_s3_key, a.thumbnail_s3_key
|
||||
FROM sequence_clips sc
|
||||
JOIN assets a ON a.id = sc.asset_id
|
||||
WHERE sc.sequence_id = $1
|
||||
ORDER BY sc.track, sc.timeline_in_frames`,
|
||||
[req.params.id]
|
||||
);
|
||||
|
||||
// Attach signed stream URLs (best-effort; missing proxy → streamUrl: null)
|
||||
const clips = await Promise.all(
|
||||
clipsR.rows.map(async (clip) => {
|
||||
let streamUrl = null;
|
||||
if (clip.proxy_s3_key) {
|
||||
try { streamUrl = await getSignedUrlForObject(clip.proxy_s3_key); } catch (_) {}
|
||||
}
|
||||
return { ...clip, streamUrl };
|
||||
})
|
||||
);
|
||||
|
||||
res.json({ ...seqR.rows[0], clips });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// ── PUT /:id – update sequence metadata ──────────────────────────────────────
|
||||
router.put('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const { name, frame_rate, width, height } = req.body;
|
||||
const updates = [];
|
||||
const params = [];
|
||||
let n = 1;
|
||||
if (name !== undefined) { updates.push(`name = $${n++}`); params.push(name); }
|
||||
if (frame_rate !== undefined) { updates.push(`frame_rate = $${n++}`); params.push(frame_rate); }
|
||||
if (width !== undefined) { updates.push(`width = $${n++}`); params.push(width); }
|
||||
if (height !== undefined) { updates.push(`height = $${n++}`); params.push(height); }
|
||||
if (!updates.length) return res.status(400).json({ error: 'No fields to update' });
|
||||
updates.push('updated_at = NOW()');
|
||||
params.push(req.params.id);
|
||||
const r = await pool.query(
|
||||
`UPDATE sequences SET ${updates.join(', ')} WHERE id = $${n} RETURNING *`,
|
||||
params
|
||||
);
|
||||
if (!r.rows.length) return res.status(404).json({ error: 'Sequence not found' });
|
||||
res.json(r.rows[0]);
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// ── DELETE /:id ───────────────────────────────────────────────────────────────
|
||||
router.delete('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const r = await pool.query(`DELETE FROM sequences WHERE id = $1`, [req.params.id]);
|
||||
if (!r.rowCount) return res.status(404).json({ error: 'Sequence not found' });
|
||||
res.json({ ok: true });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// ── PUT /:id/clips – full replace of clip array (single transaction) ──────────
|
||||
router.put('/:id/clips', async (req, res, next) => {
|
||||
// Verify sequence exists first (before acquiring transaction client)
|
||||
const seqCheck = await pool.query(`SELECT id FROM sequences WHERE id = $1`, [req.params.id]);
|
||||
if (!seqCheck.rows.length) return res.status(404).json({ error: 'Sequence not found' });
|
||||
|
||||
const clips = Array.isArray(req.body) ? req.body : [];
|
||||
for (const c of clips) {
|
||||
if (!c.asset_id) return res.status(400).json({ error: 'Each clip must have asset_id' });
|
||||
if (!Number.isFinite(Number(c.timeline_in_frames)) || !Number.isFinite(Number(c.timeline_out_frames)) ||
|
||||
!Number.isFinite(Number(c.source_in_frames)) || !Number.isFinite(Number(c.source_out_frames))) {
|
||||
return res.status(400).json({ error: 'Clip frame fields must be finite numbers' });
|
||||
}
|
||||
if (!Number.isInteger(Number(c.track)) || Number(c.track) < 0) {
|
||||
return res.status(400).json({ error: 'Clip track must be a non-negative integer' });
|
||||
}
|
||||
}
|
||||
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
await client.query(
|
||||
`DELETE FROM sequence_clips WHERE sequence_id = $1`,
|
||||
[req.params.id]
|
||||
);
|
||||
for (const c of clips) {
|
||||
await client.query(
|
||||
`INSERT INTO sequence_clips
|
||||
(sequence_id, asset_id, track,
|
||||
timeline_in_frames, timeline_out_frames,
|
||||
source_in_frames, source_out_frames)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||
[
|
||||
req.params.id, c.asset_id, c.track,
|
||||
c.timeline_in_frames, c.timeline_out_frames,
|
||||
c.source_in_frames, c.source_out_frames,
|
||||
]
|
||||
);
|
||||
}
|
||||
await client.query(
|
||||
`UPDATE sequences SET updated_at = NOW() WHERE id = $1`,
|
||||
[req.params.id]
|
||||
);
|
||||
await client.query('COMMIT');
|
||||
res.json({ ok: true, count: clips.length });
|
||||
} catch (e) {
|
||||
await client.query('ROLLBACK');
|
||||
next(e);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
// ── POST /:id/export/edl – download CMX3600 EDL ──────────────────────────────
|
||||
router.post('/:id/export/edl', async (req, res, next) => {
|
||||
try {
|
||||
const seqR = await pool.query(`SELECT * FROM sequences WHERE id = $1`, [req.params.id]);
|
||||
if (!seqR.rows.length) return res.status(404).json({ error: 'Sequence not found' });
|
||||
const seq = seqR.rows[0];
|
||||
|
||||
// Export V1 clips only (primary video track) sorted by position
|
||||
const clipsR = await pool.query(
|
||||
`SELECT sc.*, a.filename
|
||||
FROM sequence_clips sc
|
||||
JOIN assets a ON a.id = sc.asset_id
|
||||
WHERE sc.sequence_id = $1 AND sc.track = 0
|
||||
ORDER BY sc.timeline_in_frames`,
|
||||
[req.params.id]
|
||||
);
|
||||
|
||||
const edl = generateEDL(seq.name, clipsR.rows);
|
||||
const filename = `${seq.name.replace(/[^a-z0-9]/gi, '_')}.edl`;
|
||||
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
res.send(edl);
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
export default router;
|
||||
70
services/mam-api/src/routes/tokens.js
Normal file
70
services/mam-api/src/routes/tokens.js
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
/**
|
||||
* Personal API token routes (requires authentication)
|
||||
*
|
||||
* GET /api/v1/tokens — list current user's tokens (no raw values)
|
||||
* POST /api/v1/tokens — create token, returns raw value ONCE
|
||||
* DELETE /api/v1/tokens/:id — revoke token
|
||||
*/
|
||||
import express from 'express';
|
||||
import crypto from 'crypto';
|
||||
import pool from '../db/pool.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Helper: get current user ID from session or req.user
|
||||
const userId = req => req.user?.id || req.session?.userId;
|
||||
|
||||
// ── List ──────────────────────────────────────────────────────
|
||||
router.get('/', async (req, res, next) => {
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, name, token_prefix, last_used_at, expires_at, created_at
|
||||
FROM api_tokens
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC`,
|
||||
[userId(req)]
|
||||
);
|
||||
res.json(rows);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// ── Create ────────────────────────────────────────────────────
|
||||
router.post('/', async (req, res, next) => {
|
||||
try {
|
||||
const { name, expires_in_days } = req.body;
|
||||
if (!name) return res.status(400).json({ error: 'name required' });
|
||||
|
||||
// Generate: wd_ + 40 random hex chars = 43 chars total
|
||||
const raw = 'wd_' + crypto.randomBytes(20).toString('hex');
|
||||
const hash = crypto.createHash('sha256').update(raw).digest('hex');
|
||||
const prefix = raw.slice(0, 10); // "wd_" + first 7 hex chars
|
||||
|
||||
const expiresAt = expires_in_days
|
||||
? new Date(Date.now() + parseInt(expires_in_days, 10) * 86400000)
|
||||
: null;
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO api_tokens (user_id, name, token_hash, token_prefix, expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, name, token_prefix, last_used_at, expires_at, created_at`,
|
||||
[userId(req), name.trim(), hash, prefix, expiresAt]
|
||||
);
|
||||
|
||||
// Return raw token ONCE — it is never stored in plaintext
|
||||
res.status(201).json({ ...rows[0], token: raw });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// ── Revoke ────────────────────────────────────────────────────
|
||||
router.delete('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const { rowCount } = await pool.query(
|
||||
`DELETE FROM api_tokens WHERE id = $1 AND user_id = $2`,
|
||||
[req.params.id, userId(req)]
|
||||
);
|
||||
if (!rowCount) return res.status(404).json({ error: 'Token not found' });
|
||||
res.json({ message: 'Token revoked' });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
export default router;
|
||||
117
services/mam-api/src/routes/users.js
Normal file
117
services/mam-api/src/routes/users.js
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
/**
|
||||
* User management routes (admin-only when AUTH_ENABLED=true)
|
||||
*
|
||||
* GET /api/v1/users — list all users
|
||||
* POST /api/v1/users — create user
|
||||
* GET /api/v1/users/:id — get user
|
||||
* PATCH /api/v1/users/:id — update user (display_name, role, password)
|
||||
* DELETE /api/v1/users/:id — delete user
|
||||
*/
|
||||
import express from 'express';
|
||||
import bcrypt from 'bcrypt';
|
||||
import pool from '../db/pool.js';
|
||||
import { requireAuth, requireAdmin } from '../middleware/auth.js';
|
||||
|
||||
const router = express.Router();
|
||||
router.use(requireAuth, requireAdmin);
|
||||
|
||||
const VALID_ROLES = ['admin', 'editor', 'viewer'];
|
||||
|
||||
// ── List ──────────────────────────────────────────────────────
|
||||
router.get('/', async (_req, res, next) => {
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT u.id, u.username, u.display_name, u.role, u.created_at,
|
||||
COUNT(ug.group_id)::int AS group_count
|
||||
FROM users u
|
||||
LEFT JOIN user_groups ug ON ug.user_id = u.id
|
||||
GROUP BY u.id
|
||||
ORDER BY u.created_at`
|
||||
);
|
||||
res.json(rows);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// ── Create ────────────────────────────────────────────────────
|
||||
router.post('/', async (req, res, next) => {
|
||||
try {
|
||||
const { username, password, display_name, role = 'editor' } = req.body;
|
||||
if (!username || !password)
|
||||
return res.status(400).json({ error: 'username and password required' });
|
||||
if (password.length < 8)
|
||||
return res.status(400).json({ error: 'Password must be ≥ 8 characters' });
|
||||
if (!VALID_ROLES.includes(role))
|
||||
return res.status(400).json({ error: `role must be one of: ${VALID_ROLES.join(', ')}` });
|
||||
|
||||
const hash = await bcrypt.hash(password, 12);
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO users (username, password_hash, display_name, role)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, username, display_name, role, created_at`,
|
||||
[username.trim().toLowerCase(), hash, display_name || username, role]
|
||||
);
|
||||
res.status(201).json(rows[0]);
|
||||
} catch (err) {
|
||||
if (err.code === '23505') return res.status(409).json({ error: 'Username already exists' });
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Get ───────────────────────────────────────────────────────
|
||||
router.get('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, username, display_name, role, created_at FROM users WHERE id = $1`,
|
||||
[req.params.id]
|
||||
);
|
||||
if (!rows.length) return res.status(404).json({ error: 'User not found' });
|
||||
res.json(rows[0]);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// ── Update ────────────────────────────────────────────────────
|
||||
router.patch('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const { display_name, role, password } = req.body;
|
||||
const sets = []; const vals = [];
|
||||
|
||||
if (display_name !== undefined) {
|
||||
sets.push(`display_name = $${sets.length + 1}`);
|
||||
vals.push(display_name);
|
||||
}
|
||||
if (role !== undefined) {
|
||||
if (!VALID_ROLES.includes(role))
|
||||
return res.status(400).json({ error: `role must be one of: ${VALID_ROLES.join(', ')}` });
|
||||
sets.push(`role = $${sets.length + 1}`);
|
||||
vals.push(role);
|
||||
}
|
||||
if (password) {
|
||||
if (password.length < 8)
|
||||
return res.status(400).json({ error: 'Password must be ≥ 8 characters' });
|
||||
const hashed = await bcrypt.hash(password, 12);
|
||||
sets.push(`password_hash = $${sets.length + 1}`);
|
||||
vals.push(hashed);
|
||||
}
|
||||
if (!sets.length) return res.status(400).json({ error: 'Nothing to update' });
|
||||
|
||||
vals.push(req.params.id);
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE users SET ${sets.join(', ')} WHERE id = $${vals.length}
|
||||
RETURNING id, username, display_name, role, created_at`,
|
||||
vals
|
||||
);
|
||||
if (!rows.length) return res.status(404).json({ error: 'User not found' });
|
||||
res.json(rows[0]);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// ── Delete ────────────────────────────────────────────────────
|
||||
router.delete('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const { rowCount } = await pool.query('DELETE FROM users WHERE id = $1', [req.params.id]);
|
||||
if (!rowCount) return res.status(404).json({ error: 'User not found' });
|
||||
res.json({ message: 'User deleted' });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
@ -522,5 +522,6 @@
|
|||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
</script>
|
||||
<script src="js/auth-guard.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -831,5 +831,6 @@
|
|||
return `${m}:${String(s).padStart(2,'0')}`;
|
||||
}
|
||||
</script>
|
||||
<script src="js/auth-guard.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -853,5 +853,6 @@ function showError(msg) { toast(msg, 'error'); }
|
|||
loadJobs();
|
||||
</script>
|
||||
<script src="js/topbar-strip.js?v=1"></script>
|
||||
<script src="js/auth-guard.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -159,7 +159,6 @@ async function deleteProject(projectId) {
|
|||
|
||||
// ============================================================
|
||||
// BIN API CALLS
|
||||
// Bins are mounted at /api/v1/bins with project_id as query param
|
||||
// ============================================================
|
||||
|
||||
async function getBins(projectId) {
|
||||
|
|
@ -192,14 +191,8 @@ async function deleteBin(projectId, binId) {
|
|||
|
||||
// ============================================================
|
||||
// CAPTURE API CALLS
|
||||
// Routes: GET /capture/devices, GET /capture/status,
|
||||
// POST /capture/start, POST /capture/stop
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Get list of available capture devices.
|
||||
* Normalises capture service response ({index, name}) to {id, name, interface}.
|
||||
*/
|
||||
async function getCaptureDevices() {
|
||||
const result = await captureApi('/devices');
|
||||
if (result.success && result.data) {
|
||||
|
|
@ -215,23 +208,14 @@ async function getCaptureDevices() {
|
|||
return result;
|
||||
}
|
||||
|
||||
/** Get overall capture service status */
|
||||
async function getCaptureStatus() {
|
||||
return captureApi('/status');
|
||||
}
|
||||
|
||||
/** Get current recording state (alias for getCaptureStatus) */
|
||||
async function getRecordingStatus() {
|
||||
return captureApi('/status');
|
||||
}
|
||||
|
||||
/**
|
||||
* Start recording.
|
||||
* @param {number} deviceIndex - Device index from getCaptureDevices
|
||||
* @param {string} projectId
|
||||
* @param {string|null} binId
|
||||
* @param {string} clipName
|
||||
*/
|
||||
async function startRecording(deviceIndex, projectId, binId, clipName) {
|
||||
const result = await captureApi('/start', {
|
||||
method: 'POST',
|
||||
|
|
@ -248,16 +232,10 @@ async function startRecording(deviceIndex, projectId, binId, clipName) {
|
|||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the active recording.
|
||||
* Uses the session ID stored from the most recent startRecording call,
|
||||
* falling back to the current status if no local state exists.
|
||||
*/
|
||||
async function stopRecording() {
|
||||
let sessionId = _captureSessionId;
|
||||
|
||||
if (!sessionId) {
|
||||
// Try to recover session_id from live status
|
||||
const statusResult = await captureApi('/status');
|
||||
if (statusResult.success && statusResult.data && statusResult.data.sessionId) {
|
||||
sessionId = statusResult.data.sessionId;
|
||||
|
|
@ -275,9 +253,6 @@ async function stopRecording() {
|
|||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent captures — uses assets API ordered by created_at desc.
|
||||
*/
|
||||
async function getRecentCaptures(limit = 10) {
|
||||
const r = await api(`/assets?limit=${limit}`);
|
||||
if (r.success && r.data && typeof r.data === 'object' && Array.isArray(r.data.assets)) {
|
||||
|
|
@ -287,7 +262,6 @@ async function getRecentCaptures(limit = 10) {
|
|||
return r;
|
||||
}
|
||||
|
||||
/** Not available in current capture service — returns empty */
|
||||
async function getRecordingTimecode() {
|
||||
return { success: true, data: { timecode: null } };
|
||||
}
|
||||
|
|
@ -349,13 +323,8 @@ function throttle(func, limit) {
|
|||
|
||||
// ============================================================
|
||||
// UPLOAD API CALLS
|
||||
// Routes expect camelCase field names
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Initialize a multipart upload.
|
||||
* Returns { assetId, uploadId, key }
|
||||
*/
|
||||
async function initUpload(data) {
|
||||
const body = {
|
||||
filename: data.filename,
|
||||
|
|
@ -368,10 +337,6 @@ async function initUpload(data) {
|
|||
return api('/upload/init', { method: 'POST', body: JSON.stringify(body) });
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete a multipart upload.
|
||||
* @param {object} data - { uploadId, key, assetId, parts: [{partNumber, ETag}] }
|
||||
*/
|
||||
async function completeUpload(data) {
|
||||
const body = {
|
||||
uploadId: data.upload_id || data.uploadId,
|
||||
|
|
@ -385,7 +350,6 @@ async function completeUpload(data) {
|
|||
return api('/upload/complete', { method: 'POST', body: JSON.stringify(body) });
|
||||
}
|
||||
|
||||
/** Abort an ongoing multipart upload */
|
||||
async function abortUpload(data) {
|
||||
const body = {
|
||||
uploadId: data.upload_id || data.uploadId,
|
||||
|
|
@ -395,7 +359,6 @@ async function abortUpload(data) {
|
|||
return api('/upload/abort', { method: 'POST', body: JSON.stringify(body) });
|
||||
}
|
||||
|
||||
/** Upload a file part (FormData, no JSON content-type) */
|
||||
async function uploadPart(formData) {
|
||||
try {
|
||||
const response = await fetch('/api/v1/upload/part', {
|
||||
|
|
@ -411,7 +374,6 @@ async function uploadPart(formData) {
|
|||
}
|
||||
}
|
||||
|
||||
/** Simple upload for small files (under 50MB) */
|
||||
async function simpleUpload(formData) {
|
||||
try {
|
||||
const response = await fetch('/api/v1/upload/simple', {
|
||||
|
|
@ -454,3 +416,74 @@ async function deleteRecorder(id) {
|
|||
async function getRecorderStatus(id) {
|
||||
return api(`/recorders/${id}/status`);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// USERS API CALLS (admin)
|
||||
// ============================================================
|
||||
|
||||
async function getUsers() {
|
||||
return api('/users');
|
||||
}
|
||||
|
||||
async function createUser(data) {
|
||||
return api('/users', { method: 'POST', body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
async function updateUser(id, data) {
|
||||
return api(`/users/${id}`, { method: 'PATCH', body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
async function deleteUser(id) {
|
||||
return api(`/users/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// GROUPS API CALLS (admin)
|
||||
// ============================================================
|
||||
|
||||
async function getGroups() {
|
||||
return api('/groups');
|
||||
}
|
||||
|
||||
async function createGroup(data) {
|
||||
return api('/groups', { method: 'POST', body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
async function updateGroup(id, data) {
|
||||
return api(`/groups/${id}`, { method: 'PATCH', body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
async function deleteGroup(id) {
|
||||
return api(`/groups/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
async function getGroupMembers(id) {
|
||||
return api(`/groups/${id}/members`);
|
||||
}
|
||||
|
||||
async function addGroupMember(groupId, userId) {
|
||||
return api(`/groups/${groupId}/members`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ user_id: userId }),
|
||||
});
|
||||
}
|
||||
|
||||
async function removeGroupMember(groupId, userId) {
|
||||
return api(`/groups/${groupId}/members/${userId}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// TOKENS API CALLS
|
||||
// ============================================================
|
||||
|
||||
async function getTokens() {
|
||||
return api('/tokens');
|
||||
}
|
||||
|
||||
async function createToken(data) {
|
||||
return api('/tokens', { method: 'POST', body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
async function revokeToken(id) {
|
||||
return api(`/tokens/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
|
|
|||
37
services/web-ui/public/js/auth-guard.js
Normal file
37
services/web-ui/public/js/auth-guard.js
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
/**
|
||||
* auth-guard.js
|
||||
* Included on every protected page.
|
||||
*
|
||||
* - If /api/v1/auth/me returns 401 → redirect to login.html immediately.
|
||||
* (When AUTH_ENABLED=false the endpoint returns a synthetic guest user,
|
||||
* so the redirect only fires in production auth-enabled mode.)
|
||||
* - On success, populate the sidebar user widget and wire up the logout button.
|
||||
*/
|
||||
(async () => {
|
||||
try {
|
||||
const r = await fetch('/api/v1/auth/me', { credentials: 'include' });
|
||||
if (r.status === 401) {
|
||||
location.replace('login.html');
|
||||
return;
|
||||
}
|
||||
if (r.ok) {
|
||||
const u = await r.json();
|
||||
const name = u.display_name || u.username || 'User';
|
||||
const userNameEl = document.getElementById('userName');
|
||||
const userAvatarEl = document.getElementById('userAvatar');
|
||||
const userRoleEl = document.getElementById('userRole');
|
||||
if (userNameEl) userNameEl.textContent = name;
|
||||
if (userAvatarEl) userAvatarEl.textContent = name[0].toUpperCase();
|
||||
if (userRoleEl) userRoleEl.textContent = u.role || '';
|
||||
}
|
||||
} catch (_) {
|
||||
// Network error — don't redirect; the user may be on a dev build without auth.
|
||||
}
|
||||
const logoutBtn = document.getElementById('logoutBtn');
|
||||
if (logoutBtn) {
|
||||
logoutBtn.onclick = async () => {
|
||||
try { await fetch('/api/v1/auth/logout', { method: 'POST', credentials: 'include' }); } catch (_) {}
|
||||
location.href = 'login.html';
|
||||
};
|
||||
}
|
||||
})();
|
||||
|
|
@ -789,5 +789,6 @@
|
|||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
</script>
|
||||
<script src="js/auth-guard.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -295,5 +295,6 @@
|
|||
el.textContent = msg;
|
||||
}
|
||||
</script>
|
||||
<script src="js/auth-guard.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -3,703 +3,347 @@
|
|||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||
<title>Token Pricing — Z-AMPP</title>
|
||||
<title>Tokens — Wild Dragon</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="css/common.css">
|
||||
<style>
|
||||
/* GV-flavored teal-on-dark just to lean into the parody */
|
||||
.tok-main {
|
||||
flex: 1; overflow: auto;
|
||||
background:
|
||||
radial-gradient(ellipse 70% 50% at 50% 0%, oklch(38% 0.10 200 / 0.45), transparent 60%),
|
||||
radial-gradient(ellipse 80% 60% at 20% 100%, oklch(35% 0.14 195 / 0.35), transparent 65%),
|
||||
linear-gradient(135deg, oklch(20% 0.06 220), oklch(12% 0.025 230) 100%);
|
||||
}
|
||||
.tok-wrap { max-width: 1200px; margin: 0 auto; padding: 48px 32px 80px; }
|
||||
|
||||
.tok-banner {
|
||||
text-align: center;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
.tok-banner-eyebrow {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
padding: 5px 14px;
|
||||
background: oklch(15% 0.04 200);
|
||||
border: 1px solid oklch(50% 0.12 200 / 0.5);
|
||||
border-radius: 999px;
|
||||
font-size: 10px; font-weight: 700; letter-spacing: 0.18em;
|
||||
text-transform: uppercase; color: oklch(75% 0.12 200);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.tok-banner-eyebrow::before {
|
||||
content: ''; width: 6px; height: 6px;
|
||||
background: oklch(70% 0.18 200); border-radius: 50%;
|
||||
box-shadow: 0 0 10px oklch(70% 0.18 200);
|
||||
}
|
||||
.tok-title {
|
||||
font-size: 42px; font-weight: 700; letter-spacing: -0.02em;
|
||||
line-height: 1.05; color: var(--text-primary);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.tok-title .strike { text-decoration: line-through; opacity: 0.4; }
|
||||
.tok-title .pop { color: oklch(75% 0.14 200); }
|
||||
.tok-sub {
|
||||
max-width: 56ch; margin: 0 auto;
|
||||
font-size: 14px; color: oklch(70% 0.05 215); line-height: 1.55;
|
||||
}
|
||||
.tok-sub b { color: oklch(80% 0.10 200); }
|
||||
|
||||
/* ─ Tier cards ─ */
|
||||
.tok-tiers {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 16px;
|
||||
margin: 32px 0 40px;
|
||||
}
|
||||
.tok-tier {
|
||||
position: relative;
|
||||
background: oklch(13% 0.025 220 / 0.7);
|
||||
border: 1px solid oklch(35% 0.06 215 / 0.5);
|
||||
border-radius: 12px;
|
||||
padding: 22px 22px 18px;
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
.tok-tier.featured {
|
||||
border-color: oklch(60% 0.15 200 / 0.7);
|
||||
box-shadow: 0 16px 50px -16px oklch(50% 0.18 200 / 0.5);
|
||||
}
|
||||
.tok-tier-flag {
|
||||
position: absolute; top: -10px; right: 18px;
|
||||
background: oklch(58% 0.16 200);
|
||||
color: oklch(10% 0.02 220);
|
||||
font-size: 10px; font-weight: 700; letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
padding: 4px 10px; border-radius: 999px;
|
||||
}
|
||||
.tok-tier-name {
|
||||
font-size: 11px; font-weight: 700; letter-spacing: 0.18em;
|
||||
text-transform: uppercase; color: oklch(70% 0.10 200);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.tok-tier-price {
|
||||
font-size: 28px; font-weight: 700;
|
||||
color: var(--text-primary); letter-spacing: -0.01em;
|
||||
display: flex; align-items: baseline; gap: 4px;
|
||||
}
|
||||
.tok-tier-price small {
|
||||
font-size: 13px; font-weight: 500;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.tok-tier-tokens {
|
||||
margin-top: 4px;
|
||||
font-size: 12px; color: var(--text-secondary);
|
||||
font-family: var(--font-mono); letter-spacing: 0.04em;
|
||||
}
|
||||
.tok-tier-list {
|
||||
margin: 14px 0 16px; padding: 0; list-style: none;
|
||||
font-size: 12px; line-height: 1.7;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.tok-tier-list li { padding-left: 16px; position: relative; }
|
||||
.tok-tier-list li::before {
|
||||
content: '+'; position: absolute; left: 0; top: 0;
|
||||
color: oklch(70% 0.14 200); font-weight: 700;
|
||||
}
|
||||
.tok-tier-list li.minus::before { content: '−'; color: oklch(62% 0.22 25 / 0.7); }
|
||||
.tok-tier-cta {
|
||||
display: block; text-align: center;
|
||||
width: 100%; padding: 9px 12px;
|
||||
background: transparent;
|
||||
border: 1px solid oklch(50% 0.12 200 / 0.55);
|
||||
border-radius: 8px;
|
||||
color: oklch(75% 0.12 200);
|
||||
font: inherit; font-size: 12px; font-weight: 600;
|
||||
letter-spacing: 0.06em; text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
transition: background 120ms ease, border-color 120ms ease;
|
||||
}
|
||||
.tok-tier-cta:hover { background: oklch(20% 0.08 200 / 0.4); }
|
||||
.tok-tier.featured .tok-tier-cta {
|
||||
background: oklch(55% 0.16 200);
|
||||
border-color: oklch(55% 0.16 200);
|
||||
color: oklch(10% 0.02 220);
|
||||
}
|
||||
.tok-tier.featured .tok-tier-cta:hover {
|
||||
background: oklch(62% 0.18 200);
|
||||
}
|
||||
|
||||
/* ─ Per-service table ─ */
|
||||
.tok-section-head {
|
||||
display: flex; align-items: baseline; justify-content: space-between;
|
||||
margin: 40px 0 14px;
|
||||
}
|
||||
.tok-section-title {
|
||||
font-size: 11px; font-weight: 700; letter-spacing: 0.20em;
|
||||
text-transform: uppercase; color: oklch(70% 0.10 200);
|
||||
}
|
||||
.tok-section-hint {
|
||||
font-size: 11px; color: var(--text-tertiary);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.tok-table {
|
||||
width: 100%;
|
||||
background: oklch(11% 0.020 220 / 0.7);
|
||||
border: 1px solid oklch(35% 0.06 215 / 0.4);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
.tok-row {
|
||||
display: grid;
|
||||
grid-template-columns: 48px 1.4fr 1fr 0.9fr 0.9fr;
|
||||
gap: 14px;
|
||||
padding: 12px 18px;
|
||||
border-top: 1px solid oklch(35% 0.06 215 / 0.25);
|
||||
.token-card {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-lg);
|
||||
padding: var(--sp-4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
.tok-row:first-child {
|
||||
border-top: 0;
|
||||
font-size: 10px; font-weight: 700; letter-spacing: 0.16em;
|
||||
text-transform: uppercase; color: oklch(65% 0.08 200);
|
||||
padding: 14px 18px;
|
||||
.token-card-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: var(--r-md);
|
||||
background: var(--accent-subtle);
|
||||
border: 1px solid var(--accent-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tok-row-icon {
|
||||
width: 32px; height: 32px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: oklch(15% 0.05 215);
|
||||
border: 1px solid oklch(45% 0.12 200 / 0.4);
|
||||
border-radius: 8px;
|
||||
color: oklch(72% 0.12 200);
|
||||
.token-card-body { flex: 1; min-width: 0; }
|
||||
.token-card-name { font-weight: 500; font-size: var(--text-sm); }
|
||||
.token-card-meta { font-size: var(--text-xs); color: var(--text-tertiary); margin-top: 2px; }
|
||||
.token-prefix {
|
||||
font-family: 'SF Mono', 'Consolas', monospace;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-raised);
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--r-sm);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.tok-row-icon svg { width: 16px; height: 16px; }
|
||||
.tok-row-name { font-weight: 500; color: var(--text-primary); }
|
||||
.tok-row-name small {
|
||||
display: block;
|
||||
font-size: 11px; font-weight: 400;
|
||||
color: var(--text-tertiary); margin-top: 2px;
|
||||
.tokens-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-2);
|
||||
max-width: 680px;
|
||||
}
|
||||
.tok-row-meter, .tok-row-rate, .tok-row-mult {
|
||||
font-family: var(--font-mono); font-size: 12px;
|
||||
color: var(--text-secondary); letter-spacing: 0.04em;
|
||||
.new-token-banner {
|
||||
background: var(--status-green-bg);
|
||||
border: 1px solid oklch(68% 0.18 148 / 0.30);
|
||||
border-radius: var(--r-lg);
|
||||
padding: var(--sp-4) var(--sp-5);
|
||||
margin-bottom: var(--sp-5);
|
||||
max-width: 680px;
|
||||
}
|
||||
.tok-row-rate b { color: oklch(78% 0.14 200); font-weight: 600; }
|
||||
.tok-row-mult.hot { color: oklch(70% 0.18 25); }
|
||||
.tok-row-mult.cold { color: oklch(70% 0.14 145); }
|
||||
|
||||
/* ─ Calculator ─ */
|
||||
.tok-calc {
|
||||
margin-top: 36px;
|
||||
background: oklch(13% 0.025 220 / 0.7);
|
||||
border: 1px solid oklch(35% 0.06 215 / 0.5);
|
||||
border-radius: 12px;
|
||||
padding: 22px 24px;
|
||||
.new-token-banner-title {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
color: var(--status-green);
|
||||
margin-bottom: var(--sp-2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
.tok-calc-head { margin-bottom: 14px; }
|
||||
.tok-calc-title { font-size: 16px; font-weight: 600; color: var(--text-primary); }
|
||||
.tok-calc-sub { font-size: 12px; color: var(--text-tertiary); margin-top: 4px; }
|
||||
.tok-calc-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
margin: 16px 0 18px;
|
||||
.new-token-banner-warning {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
margin-top: var(--sp-3);
|
||||
}
|
||||
.tok-calc-field {
|
||||
display: flex; flex-direction: column; gap: 4px;
|
||||
.copy-btn {
|
||||
margin-top: var(--sp-3);
|
||||
}
|
||||
.tok-calc-label {
|
||||
font-size: 10px; font-weight: 600;
|
||||
letter-spacing: 0.14em; text-transform: uppercase;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.tok-calc-field input {
|
||||
padding: 8px 12px;
|
||||
background: oklch(8% 0.015 220);
|
||||
border: 1px solid oklch(35% 0.06 215 / 0.5);
|
||||
border-radius: 6px;
|
||||
color: var(--text-primary); font: inherit;
|
||||
font-family: var(--font-mono); font-size: 14px;
|
||||
}
|
||||
.tok-calc-out {
|
||||
padding: 16px 18px;
|
||||
background: oklch(8% 0.015 220);
|
||||
border: 1px solid oklch(50% 0.12 200 / 0.4);
|
||||
border-radius: 8px;
|
||||
display: flex; flex-wrap: wrap; gap: 24px; align-items: baseline;
|
||||
}
|
||||
.tok-calc-out-total {
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
.tok-calc-out-label {
|
||||
font-size: 10px; font-weight: 600;
|
||||
letter-spacing: 0.18em; text-transform: uppercase;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.tok-calc-out-value {
|
||||
font-size: 28px; font-weight: 700;
|
||||
color: oklch(82% 0.12 200);
|
||||
font-family: var(--font-mono); letter-spacing: -0.02em;
|
||||
}
|
||||
.tok-calc-out-aside {
|
||||
font-size: 11px; color: var(--text-tertiary);
|
||||
max-width: 36ch; line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ─ Footnote micro-print ─ */
|
||||
.tok-footer {
|
||||
margin-top: 36px;
|
||||
padding: 18px 22px;
|
||||
background: oklch(8% 0.015 220 / 0.6);
|
||||
border: 1px solid oklch(30% 0.04 215 / 0.4);
|
||||
border-radius: 10px;
|
||||
font-size: 10px; line-height: 1.6;
|
||||
color: oklch(55% 0.04 215);
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
.tok-footer b { color: oklch(70% 0.06 215); font-weight: 600; }
|
||||
.tok-footer p { margin: 0 0 8px; }
|
||||
.tok-footer p:last-child { margin: 0; }
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.tok-row { grid-template-columns: 36px 1fr 1fr; gap: 10px; padding: 10px 12px; font-size: 12px; }
|
||||
.tok-row > :nth-child(4), .tok-row > :nth-child(5) { display: none; }
|
||||
.tok-title { font-size: 30px; }
|
||||
}
|
||||
|
||||
/* ─ Token burn chart ─ */
|
||||
.tok-chart {
|
||||
background: oklch(11% 0.020 220 / 0.7);
|
||||
border: 1px solid oklch(35% 0.06 215 / 0.5);
|
||||
border-radius: 12px;
|
||||
padding: 20px 22px 22px;
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
.tok-chart-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.tok-stat {
|
||||
display: flex; flex-direction: column; gap: 2px;
|
||||
padding: 10px 12px;
|
||||
background: oklch(8% 0.015 220 / 0.7);
|
||||
border: 1px solid oklch(35% 0.06 215 / 0.3);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.tok-stat-label { font-size: 10px; font-weight: 600; letter-spacing: 0.14em; text-transform: uppercase; color: var(--text-tertiary); }
|
||||
.tok-stat-value { font-family: var(--font-mono); font-size: 20px; font-weight: 700; color: var(--text-primary); letter-spacing: -0.01em; }
|
||||
.tok-stat-delta { font-size: 10px; font-weight: 600; color: var(--text-tertiary); letter-spacing: 0.04em; }
|
||||
.tok-stat-delta.hot { color: oklch(70% 0.18 25); }
|
||||
.tok-stat-delta.cold { color: oklch(70% 0.14 145); }
|
||||
.tok-chart-frame {
|
||||
position: relative;
|
||||
background: oklch(8% 0.015 220 / 0.7);
|
||||
border: 1px solid oklch(35% 0.06 215 / 0.3);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
}
|
||||
.tok-chart-svg { width: 100%; height: 260px; display: block; }
|
||||
.tok-chart-legend {
|
||||
display: flex; flex-wrap: wrap; gap: 18px;
|
||||
margin-top: 10px;
|
||||
font-size: 11px; color: var(--text-secondary);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.tok-chart-legend span { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.tok-chart-legend i { display: inline-block; width: 10px; height: 10px; border-radius: 2px; }
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="shell">
|
||||
|
||||
<!-- Sidebar -->
|
||||
<nav class="sidebar" aria-label="Main navigation">
|
||||
<div class="sidebar-brand">
|
||||
<img src="img/dragon-logo.png?v=1" alt="Wild Dragon" class="sidebar-logo">
|
||||
<span class="sidebar-brand-name">Z-AMPP</span>
|
||||
<div class="sidebar-brand-mark">
|
||||
<svg viewBox="0 0 16 16" fill="currentColor" width="12" height="12"><path d="M8 1L2 5v6l6 4 6-4V5L8 1zm0 2.2L12 6v4l-4 2.7L4 10V6l4-2.8z"/></svg>
|
||||
</div>
|
||||
<span class="sidebar-brand-name">Wild Dragon</span>
|
||||
</div>
|
||||
<nav class="sidebar-nav">
|
||||
<a href="home.html" class="nav-item"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 7l6-5 6 5v7a1 1 0 0 1-1 1h-3v-5H6v5H3a1 1 0 0 1-1-1z"/></svg>Home</a>
|
||||
<a href="index.html" class="nav-item"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="1" width="6" height="6" rx="1"/><rect x="9" y="1" width="6" height="6" rx="1"/><rect x="1" y="9" width="6" height="6" rx="1"/><rect x="9" y="9" width="6" height="6" rx="1"/></svg>Library</a>
|
||||
<a href="projects.html" class="nav-item"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M1 4a1 1 0 0 1 1-1h4l2 2h5a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1z"/></svg>Projects</a>
|
||||
<a href="upload.html" class="nav-item"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 11V3M5 6l3-3 3 3"/><path d="M2 13h12"/></svg>Ingest</a>
|
||||
<a href="recorders.html" class="nav-item"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="4" width="10" height="8" rx="1"/><path d="M11 7l4-2v6l-4-2"/></svg>Recorders</a>
|
||||
<a href="capture.html" class="nav-item"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="3"/><circle cx="8" cy="8" r="6.5"/></svg>Capture</a>
|
||||
<a href="edit.html" class="nav-item"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 13l1.5-3.5L11 2l3 3-7.5 7.5L3 14zM10 3l3 3"/></svg>Editor</a>
|
||||
<a href="jobs.html" class="nav-item"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 4h12M2 8h8M2 12h5"/></svg>Jobs</a>
|
||||
<a href="tokens.html" class="nav-item active"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="6"/><path d="M5.5 9.5h3a1.5 1.5 0 0 0 0-3h-1a1.5 1.5 0 0 1 0-3h3M8 3v1m0 8v1"/></svg>Tokens</a>
|
||||
<a href="index.html" class="nav-item">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="1" width="6" height="6" rx="1"/><rect x="9" y="1" width="6" height="6" rx="1"/><rect x="1" y="9" width="6" height="6" rx="1"/><rect x="9" y="9" width="6" height="6" rx="1"/></svg>
|
||||
Library
|
||||
</a>
|
||||
<a href="upload.html" class="nav-item">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 11V3M5 6l3-3 3 3"/><path d="M2 13h12"/></svg>
|
||||
Ingest
|
||||
</a>
|
||||
<a href="recorders.html" class="nav-item">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="4" width="10" height="8" rx="1"/><path d="M11 7l4-2v6l-4-2"/></svg>
|
||||
Recorders
|
||||
</a>
|
||||
<a href="capture.html" class="nav-item">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="3"/><circle cx="8" cy="8" r="6.5"/></svg>
|
||||
Capture
|
||||
</a>
|
||||
<a href="jobs.html" class="nav-item">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 4h12M2 8h8M2 12h5"/></svg>
|
||||
Jobs
|
||||
</a>
|
||||
<div class="sidebar-section-label">Admin</div>
|
||||
<a href="settings.html" class="nav-item">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="2.5"/><path d="M8 1.5v1M8 13.5v1M1.5 8h1M13.5 8h1M3.2 3.2l.7.7M12.1 12.1l.7.7M12.1 3.9l-.7.7M3.9 12.1l-.7.7"/></svg>
|
||||
Settings
|
||||
</a>
|
||||
<a href="users.html" class="nav-item">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="6" cy="5" r="2.5"/><path d="M1 13c0-2.8 2.2-5 5-5s5 2.2 5 5"/><circle cx="12" cy="5" r="2"/><path d="M15 12c0-1.9-1.3-3.5-3-4"/></svg>
|
||||
Users
|
||||
</a>
|
||||
<a href="tokens.html" class="nav-item active">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="6" cy="10" r="3.5"/><path d="M8.7 7.3L13 3M11.5 3.5l1.5 1.5M13.5 2.5l1 1"/></svg>
|
||||
Tokens
|
||||
</a>
|
||||
</nav>
|
||||
<div class="sidebar-footer">
|
||||
<div class="sidebar-user">
|
||||
<div class="sidebar-user-avatar" id="userAvatar">?</div>
|
||||
<div class="sidebar-user-info">
|
||||
<div class="sidebar-user-name" id="userName">—</div>
|
||||
<div class="sidebar-user-role" id="userRole"></div>
|
||||
</div>
|
||||
<button class="btn btn-ghost" id="logoutBtn" title="Sign out" style="padding:0;width:24px;height:24px;flex-shrink:0;">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" width="14" height="14"><path d="M10 8H3M6 5l-3 3 3 3"/><path d="M7 3h5a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1H7"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="main tok-main">
|
||||
<!-- Main -->
|
||||
<div class="main">
|
||||
<header class="topbar">
|
||||
<div class="topbar-left">
|
||||
<span class="page-title">Token Pricing</span>
|
||||
<span class="topbar-sep">/</span>
|
||||
<span class="text-sm" style="color:var(--text-tertiary)">Enterprise Compute Compliance Engine v4.7</span>
|
||||
<span class="page-title">API Tokens</span>
|
||||
</div>
|
||||
<div class="topbar-right">
|
||||
<button class="btn btn-ghost btn-sm" onclick="alert('Your account executive will be in touch.\\n\\nEstimated response time: 6–12 business days.')">Talk to sales</button>
|
||||
<button class="btn btn-primary btn-sm" onclick="openNewTokenPanel()">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 2v12M2 8h12"/></svg>
|
||||
New token
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="tok-wrap">
|
||||
|
||||
<!-- Banner -->
|
||||
<div class="tok-banner">
|
||||
<span class="tok-banner-eyebrow">Z-AMPP Pricing</span>
|
||||
<h1 class="tok-title"><span class="strike">Per-seat</span> · <span class="strike">Per-stream</span> · <span class="strike">Per-month</span><br><span class="pop">Per token.</span></h1>
|
||||
<p class="tok-sub">Welcome to the future of broadcast media operations. Tokens are <b>fungible compute credits</b> that flexibly meter every action across the Platform™. Move faster. Pay precisely. Forecast nothing.</p>
|
||||
<div class="page-content">
|
||||
<div style="max-width:680px;margin-bottom:var(--sp-5);">
|
||||
<p class="text-sm text-secondary" style="line-height:1.7;">
|
||||
API tokens let scripts and integrations authenticate as you without using your password.
|
||||
Tokens are shown once at creation — store them securely.
|
||||
Use <code style="font-family:monospace;font-size:11px;background:var(--bg-surface);padding:1px 5px;border-radius:3px;border:1px solid var(--border)">Authorization: Bearer <token></code> in your requests.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Tiers -->
|
||||
<div class="tok-tiers">
|
||||
|
||||
<div class="tok-tier">
|
||||
<div class="tok-tier-name">Starter</div>
|
||||
<div class="tok-tier-price">$499<small> / mo</small></div>
|
||||
<div class="tok-tier-tokens">100,000 tokens · $4.99 / 1k</div>
|
||||
<ul class="tok-tier-list">
|
||||
<li>1 concurrent recorder</li>
|
||||
<li>SD ingest (480p · 1.2× multiplier)</li>
|
||||
<li>Standard support · email · 96h SLA</li>
|
||||
<li class="minus">No HD codec access</li>
|
||||
<li class="minus">No ProRes write</li>
|
||||
</ul>
|
||||
<button class="tok-tier-cta" onclick="addToCart('Starter')">Get started</button>
|
||||
<!-- New token reveal (shown after creation) -->
|
||||
<div id="newTokenBanner" style="display:none;" class="new-token-banner">
|
||||
<div class="new-token-banner-title">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" width="14" height="14"><circle cx="8" cy="8" r="6.5"/><path d="M5 8l2 2 4-4"/></svg>
|
||||
Token created
|
||||
</div>
|
||||
<div class="token-reveal" id="newTokenValue"></div>
|
||||
<div class="new-token-banner-warning">
|
||||
⚠ This is the only time this token will be shown. Copy it now.
|
||||
</div>
|
||||
<button class="btn btn-secondary btn-sm copy-btn" onclick="copyToken()">Copy to clipboard</button>
|
||||
</div>
|
||||
|
||||
<div class="tok-tier featured">
|
||||
<span class="tok-tier-flag">Most flexible</span>
|
||||
<div class="tok-tier-name">Professional</div>
|
||||
<div class="tok-tier-price">$2,499<small> / mo</small></div>
|
||||
<div class="tok-tier-tokens">600,000 tokens · $4.17 / 1k</div>
|
||||
<ul class="tok-tier-list">
|
||||
<li>4 concurrent recorders</li>
|
||||
<li>HD ingest (1080p · 3.2× multiplier)</li>
|
||||
<li>ProRes HQ write (2.4× multiplier)</li>
|
||||
<li>Priority queue · 24h SLA</li>
|
||||
<li class="minus">4K surcharge applies</li>
|
||||
</ul>
|
||||
<button class="tok-tier-cta" onclick="addToCart('Professional')">Provision tier</button>
|
||||
<!-- Token list -->
|
||||
<div class="tokens-list" id="tokensList">
|
||||
<div style="color:var(--text-tertiary);font-size:var(--text-sm)">Loading…</div>
|
||||
</div>
|
||||
|
||||
<div class="tok-tier">
|
||||
<div class="tok-tier-name">Broadcast</div>
|
||||
<div class="tok-tier-price">$8,999<small> / mo</small></div>
|
||||
<div class="tok-tier-tokens">2,400,000 tokens · $3.75 / 1k</div>
|
||||
<ul class="tok-tier-list">
|
||||
<li>12 concurrent recorders</li>
|
||||
<li>4K ingest (5.8× multiplier)</li>
|
||||
<li>ProRes 4444 write (4.0× multiplier)</li>
|
||||
<li>Named CSM · phone · 4h SLA</li>
|
||||
<li>Token rollover (90 days, fees apply)</li>
|
||||
</ul>
|
||||
<button class="tok-tier-cta" onclick="addToCart('Broadcast')">Engage account team</button>
|
||||
<div id="tokensEmpty" class="empty-state" style="display:none;padding:var(--sp-12) 0;">
|
||||
<div class="empty-state-icon">
|
||||
<svg viewBox="0 0 40 40" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="15" cy="25" r="8"/><path d="M21 19l10-10M28 10l3 3M30 8l2 2"/></svg>
|
||||
</div>
|
||||
|
||||
<div class="tok-tier">
|
||||
<div class="tok-tier-name">Enterprise</div>
|
||||
<div class="tok-tier-price">Contact us</div>
|
||||
<div class="tok-tier-tokens">Custom token allocation</div>
|
||||
<ul class="tok-tier-list">
|
||||
<li>Unlimited* concurrent recorders</li>
|
||||
<li>8K / IMF / DCP write tiers</li>
|
||||
<li>Dedicated solutions architect</li>
|
||||
<li>Quarterly token true-up audits</li>
|
||||
<li class="minus">Implementation fee not included</li>
|
||||
</ul>
|
||||
<button class="tok-tier-cta" onclick="addToCart('Enterprise')">Request quotation</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Per-service table -->
|
||||
<div class="tok-section-head">
|
||||
<span class="tok-section-title">Per-Service Metering</span>
|
||||
<span class="tok-section-hint">All rates exclusive of TVM · effective Q3 FY26</span>
|
||||
</div>
|
||||
|
||||
<div class="tok-table">
|
||||
<div class="tok-row">
|
||||
<span></span>
|
||||
<span>Service</span>
|
||||
<span>Meter</span>
|
||||
<span>Base rate</span>
|
||||
<span>Multiplier</span>
|
||||
</div>
|
||||
|
||||
<div class="tok-row">
|
||||
<div class="tok-row-icon"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="1" width="6" height="6" rx="1"/><rect x="9" y="1" width="6" height="6" rx="1"/><rect x="1" y="9" width="6" height="6" rx="1"/><rect x="9" y="9" width="6" height="6" rx="1"/></svg></div>
|
||||
<div class="tok-row-name">Library<small>Asset browse, search, thumbnail render</small></div>
|
||||
<div class="tok-row-meter">Per asset · per hour</div>
|
||||
<div class="tok-row-rate"><b>0.012</b> tokens</div>
|
||||
<div class="tok-row-mult">1.00×</div>
|
||||
</div>
|
||||
|
||||
<div class="tok-row">
|
||||
<div class="tok-row-icon"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 11V3M5 6l3-3 3 3"/><path d="M2 13h12"/></svg></div>
|
||||
<div class="tok-row-name">Ingest<small>Upload + transcode to managed proxy</small></div>
|
||||
<div class="tok-row-meter">Per GB · per pass</div>
|
||||
<div class="tok-row-rate"><b>14.4</b> tokens / GB</div>
|
||||
<div class="tok-row-mult hot">2.4× during business hours</div>
|
||||
</div>
|
||||
|
||||
<div class="tok-row">
|
||||
<div class="tok-row-icon"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="4" width="10" height="8" rx="1"/><path d="M11 7l4-2v6l-4-2"/></svg></div>
|
||||
<div class="tok-row-name">Recorder · SRT<small>Caller-mode network ingest, includes HLS preview*</small></div>
|
||||
<div class="tok-row-meter">Per minute · per recorder</div>
|
||||
<div class="tok-row-rate"><b>4.8</b> tokens / min</div>
|
||||
<div class="tok-row-mult hot">+22% Reliability Adjustment</div>
|
||||
</div>
|
||||
|
||||
<div class="tok-row">
|
||||
<div class="tok-row-icon"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="4" width="10" height="8" rx="1"/><path d="M11 7l4-2v6l-4-2"/></svg></div>
|
||||
<div class="tok-row-name">Recorder · RTMP<small>Generic ingest tier · legacy codec compatibility</small></div>
|
||||
<div class="tok-row-meter">Per minute · per recorder</div>
|
||||
<div class="tok-row-rate"><b>3.6</b> tokens / min</div>
|
||||
<div class="tok-row-mult">1.00×</div>
|
||||
</div>
|
||||
|
||||
<div class="tok-row">
|
||||
<div class="tok-row-icon"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="3"/><circle cx="8" cy="8" r="6.5"/></svg></div>
|
||||
<div class="tok-row-name">Capture · SDI<small>DeckLink baseband ingest · 12G-SDI add-on available</small></div>
|
||||
<div class="tok-row-meter">Per minute · per SDI channel</div>
|
||||
<div class="tok-row-rate"><b>9.2</b> tokens / min</div>
|
||||
<div class="tok-row-mult hot">1.8× premium baseband multiplier</div>
|
||||
</div>
|
||||
|
||||
<div class="tok-row">
|
||||
<div class="tok-row-icon"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="3"/><circle cx="13" cy="3" r="1"/></svg></div>
|
||||
<div class="tok-row-name">Live HLS Preview<small>Real-time delivery acceleration (RTDA™)</small></div>
|
||||
<div class="tok-row-meter">Per active viewer · per second</div>
|
||||
<div class="tok-row-rate"><b>0.0008</b> tokens</div>
|
||||
<div class="tok-row-mult hot">3.2× CDN egress premium</div>
|
||||
</div>
|
||||
|
||||
<div class="tok-row">
|
||||
<div class="tok-row-icon"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="6"/><path d="M5 8h6M8 5v6"/></svg></div>
|
||||
<div class="tok-row-name">ProRes HQ Write<small>Mastering-grade codec licensing</small></div>
|
||||
<div class="tok-row-meter">Per minute of media</div>
|
||||
<div class="tok-row-rate"><b>6.4</b> tokens / min</div>
|
||||
<div class="tok-row-mult hot">2.4× codec entitlement fee</div>
|
||||
</div>
|
||||
|
||||
<div class="tok-row">
|
||||
<div class="tok-row-icon"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 13l1.5-3.5L11 2l3 3-7.5 7.5L3 14zM10 3l3 3"/></svg></div>
|
||||
<div class="tok-row-name">Editor render<small>Server-side concat / trim · brand-aligned codec ladder</small></div>
|
||||
<div class="tok-row-meter">Per minute of output</div>
|
||||
<div class="tok-row-rate"><b>11.8</b> tokens / min</div>
|
||||
<div class="tok-row-mult hot">+18% Real-Time Render Surcharge</div>
|
||||
</div>
|
||||
|
||||
<div class="tok-row">
|
||||
<div class="tok-row-icon"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 4h12M2 8h8M2 12h5"/></svg></div>
|
||||
<div class="tok-row-name">Background Jobs<small>Proxy gen, thumbnails, AMPP folder sync</small></div>
|
||||
<div class="tok-row-meter">Per job · per CPU-second</div>
|
||||
<div class="tok-row-rate"><b>0.45</b> tokens</div>
|
||||
<div class="tok-row-mult cold">0.85× off-peak discount</div>
|
||||
</div>
|
||||
|
||||
<div class="tok-row">
|
||||
<div class="tok-row-icon"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 3h12v10H2z"/><path d="M5 6h6M5 9h4"/></svg></div>
|
||||
<div class="tok-row-name">Premiere Pro Connector<small>CEP bridge · per-NLE seat compatibility license</small></div>
|
||||
<div class="tok-row-meter">Per workstation · per month</div>
|
||||
<div class="tok-row-rate"><b>22,000</b> tokens</div>
|
||||
<div class="tok-row-mult hot">+ $99 NLE Compatibility Levy</div>
|
||||
</div>
|
||||
|
||||
<div class="tok-row">
|
||||
<div class="tok-row-icon"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="6"/><path d="M8 4v4l3 2"/></svg></div>
|
||||
<div class="tok-row-name">API call<small>GET /api/v1/* · includes 200-byte response budget</small></div>
|
||||
<div class="tok-row-meter">Per request</div>
|
||||
<div class="tok-row-rate"><b>0.0011</b> tokens</div>
|
||||
<div class="tok-row-mult">1.00× (overage 3.4×)</div>
|
||||
<div class="empty-state-title">No tokens yet</div>
|
||||
<div class="empty-state-body">Create a token to authenticate API requests without a password.</div>
|
||||
<div class="empty-state-actions">
|
||||
<button class="btn btn-primary btn-sm" onclick="openNewTokenPanel()">New token</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Usage chart -->
|
||||
<div class="tok-section-head">
|
||||
<span class="tok-section-title">Current Token Burn</span>
|
||||
<span class="tok-section-hint" id="chartHint">Last 14 days · nightly true-up · TVM applied</span>
|
||||
</div>
|
||||
<div class="tok-chart">
|
||||
<div class="tok-chart-stats">
|
||||
<div class="tok-stat"><span class="tok-stat-label">MTD burn</span><span class="tok-stat-value" id="statMtd">—</span><span class="tok-stat-delta hot" id="statMtdDelta">+0%</span></div>
|
||||
<div class="tok-stat"><span class="tok-stat-label">Forecast EOM</span><span class="tok-stat-value" id="statEom">—</span><span class="tok-stat-delta hot" id="statEomDelta">over plan</span></div>
|
||||
<div class="tok-stat"><span class="tok-stat-label">TVM (live)</span><span class="tok-stat-value" id="statTvm">—</span><span class="tok-stat-delta" id="statTvmTrend">stable</span></div>
|
||||
<div class="tok-stat"><span class="tok-stat-label">Peak draw</span><span class="tok-stat-value" id="statPeak">—</span><span class="tok-stat-delta cold" id="statPeakDay">Wed</span></div>
|
||||
</div>
|
||||
<div class="tok-chart-frame">
|
||||
<svg class="tok-chart-svg" id="burnChart" viewBox="0 0 800 220" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg"></svg>
|
||||
<div class="tok-chart-legend">
|
||||
<span><i style="background:oklch(70% 0.18 200)"></i>Ingest</span>
|
||||
<span><i style="background:oklch(62% 0.15 145)"></i>Recorders</span>
|
||||
<span><i style="background:oklch(70% 0.18 80)"></i>Render</span>
|
||||
<span><i style="background:oklch(62% 0.22 25)"></i>Overage</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Calculator -->
|
||||
<div class="tok-calc">
|
||||
<div class="tok-calc-head">
|
||||
<div class="tok-calc-title">Monthly token estimator</div>
|
||||
<div class="tok-calc-sub">Honest forecasts since 2019. Actual usage may vary by up to 340%.</div>
|
||||
<!-- New token slide panel -->
|
||||
<div class="slide-overlay" id="tokenOverlay" onclick="closeNewTokenPanel()"></div>
|
||||
<div class="slide-panel" id="tokenPanel">
|
||||
<div class="slide-panel-header">
|
||||
<span class="slide-panel-title">New API token</span>
|
||||
<button class="btn btn-ghost btn-sm" onclick="closeNewTokenPanel()" style="padding:0;width:28px;height:28px;">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 3l10 10M13 3L3 13"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="tok-calc-grid">
|
||||
<div class="tok-calc-field"><label class="tok-calc-label">Ingest GB/mo</label><input id="iIngest" type="number" value="800" min="0"></div>
|
||||
<div class="tok-calc-field"><label class="tok-calc-label">SRT recorder hours</label><input id="iSrt" type="number" value="120" min="0"></div>
|
||||
<div class="tok-calc-field"><label class="tok-calc-label">SDI capture hours</label><input id="iSdi" type="number" value="40" min="0"></div>
|
||||
<div class="tok-calc-field"><label class="tok-calc-label">Premiere seats</label><input id="iSeats" type="number" value="3" min="0"></div>
|
||||
<div class="tok-calc-field"><label class="tok-calc-label">Editor render min/mo</label><input id="iRender" type="number" value="240" min="0"></div>
|
||||
<div class="slide-panel-body">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="tokenName">Token name</label>
|
||||
<input type="text" id="tokenName" placeholder="e.g. Premiere Plugin, CI/CD Script">
|
||||
<div class="form-hint">A label to help you remember what this token is for.</div>
|
||||
</div>
|
||||
<div class="tok-calc-out">
|
||||
<div class="tok-calc-out-total">
|
||||
<span class="tok-calc-out-label">Estimated monthly tokens</span>
|
||||
<span class="tok-calc-out-value" id="calcTokens">—</span>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="tokenExpiry">Expiry</label>
|
||||
<select id="tokenExpiry">
|
||||
<option value="">No expiry</option>
|
||||
<option value="30">30 days</option>
|
||||
<option value="90">90 days</option>
|
||||
<option value="365">1 year</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="tok-calc-out-total">
|
||||
<span class="tok-calc-out-label">At Professional tier</span>
|
||||
<span class="tok-calc-out-value" id="calcDollars" style="color:oklch(82% 0.10 25)">—</span>
|
||||
</div>
|
||||
<div class="tok-calc-out-aside" id="calcNote">Includes 2.4× business-hours Ingest multiplier. Excludes overage, peak-hour surcharge, codec entitlement, and the Token Velocity Modifier (TVM™), which fluctuates between 0.8× and 4.2× without notice.</div>
|
||||
<div class="slide-panel-footer">
|
||||
<button class="btn btn-ghost" onclick="closeNewTokenPanel()">Cancel</button>
|
||||
<button class="btn btn-primary" id="createTokenBtn" onclick="createNewToken()">Create token</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footnote micro-print -->
|
||||
<div class="tok-footer">
|
||||
<p><b>Disclosures.</b> All rates quoted in Z-Tokens®, a non-transferable digital unit of account valid only within the Platform™. One (1) token is equivalent to 1.0 token at time of redemption, subject to the Token Velocity Modifier (TVM™), which is recalculated nightly and applied retroactively where contractually permitted. Tokens expire after 30 days unless rolled over with the Token Continuity Add-On (TCA, sold separately).</p>
|
||||
<p><b>Multipliers.</b> "Reliability Adjustment", "Real-Time Render Surcharge", and "Premium Baseband Multiplier" are not surcharges; they are <i>positive entitlements</i> that grant continued access to services for which you have already paid. Refusal to pay constitutes voluntary entitlement waiver.</p>
|
||||
<p><b>Token Compatibility Levy.</b> A 14% sustainability levy is automatically applied to all token consumption in support of the Platform's commitment to operational excellence. The levy is non-refundable, non-itemized, and not represented above.</p>
|
||||
<p><b>Forward-looking statements.</b> Anything resembling a price on this page is illustrative and is not, has not been, and will never be, a price. Pricing is determined exclusively by your assigned Customer Success Outcome Architect during quarterly value-realization workshops.</p>
|
||||
<p style="margin-top:14px;font-style:italic;opacity:0.7"><b>Z-AMPP</b> is the broadcast asset management platform you actually own. No token has ever been minted, charged, or considered. This page exists for purely educational purposes. Any resemblance to a real metered-compute pricing model is entirely intentional and deeply affectionate.</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="toast-container" id="toastContainer" aria-live="polite"></div>
|
||||
|
||||
<script src="js/api.js"></script>
|
||||
<script src="js/topbar-strip.js"></script>
|
||||
<script>
|
||||
function addToCart(tier) {
|
||||
const lines = [
|
||||
'You have selected the ' + tier + ' tier.',
|
||||
'',
|
||||
'A Customer Success Outcome Architect will reach out',
|
||||
'within 6–12 business days to schedule your initial',
|
||||
'discovery + value-alignment workshop.',
|
||||
'',
|
||||
'Until then, please continue using Z-AMPP for free.',
|
||||
'Because it is free. Because we built it ourselves.',
|
||||
];
|
||||
alert(lines.join('\n'));
|
||||
let latestToken = null;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', loadTokens);
|
||||
|
||||
async function loadTokens() {
|
||||
const r = await getTokens();
|
||||
const list = document.getElementById('tokensList');
|
||||
const empty = document.getElementById('tokensEmpty');
|
||||
|
||||
if (!r.success) {
|
||||
list.innerHTML = `<div class="text-sm text-tertiary">Could not load tokens.</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculator
|
||||
const tvm = 1.42; // current "Token Velocity Modifier"
|
||||
function calc() {
|
||||
const g = parseFloat(document.getElementById('iIngest').value || '0');
|
||||
const srt = parseFloat(document.getElementById('iSrt').value || '0');
|
||||
const sdi = parseFloat(document.getElementById('iSdi').value || '0');
|
||||
const seats = parseFloat(document.getElementById('iSeats').value || '0');
|
||||
const ren = parseFloat(document.getElementById('iRender').value || '0');
|
||||
// Made-up math
|
||||
const tokens = Math.round(
|
||||
g * 14.4 * 2.4 +
|
||||
srt * 60 * 4.8 * 1.22 +
|
||||
sdi * 60 * 9.2 * 1.8 +
|
||||
seats * 22000 +
|
||||
ren * 11.8 * 1.18
|
||||
);
|
||||
const withLevy = Math.round(tokens * 1.14 * tvm);
|
||||
document.getElementById('calcTokens').textContent = withLevy.toLocaleString();
|
||||
const dollars = withLevy / 1000 * 4.17;
|
||||
document.getElementById('calcDollars').textContent = '$' + Math.round(dollars).toLocaleString();
|
||||
const tokens = r.data;
|
||||
if (!tokens.length) {
|
||||
list.style.display = 'none';
|
||||
empty.style.display = 'flex';
|
||||
return;
|
||||
}
|
||||
document.querySelectorAll('.tok-calc-field input').forEach(i => i.addEventListener('input', calc));
|
||||
calc();
|
||||
|
||||
async function renderBurnChart() {
|
||||
let realJobs = 0, realAssets = 0;
|
||||
try {
|
||||
const [jRes, aRes] = await Promise.all([
|
||||
fetch('/api/v1/jobs', { credentials: 'include' }),
|
||||
fetch('/api/v1/assets?limit=1', { credentials: 'include' }),
|
||||
]);
|
||||
if (jRes.ok) realJobs = (await jRes.json()).length;
|
||||
if (aRes.ok) realAssets = (await aRes.json()).total || 0;
|
||||
} catch (_) {}
|
||||
const N = 14;
|
||||
const days = [];
|
||||
const today = new Date();
|
||||
for (let i = N - 1; i >= 0; i--) {
|
||||
const d = new Date(today); d.setDate(d.getDate() - i);
|
||||
days.push(d);
|
||||
list.style.display = 'flex';
|
||||
empty.style.display = 'none';
|
||||
|
||||
list.innerHTML = tokens.map(t => {
|
||||
const created = t.created_at ? new Date(t.created_at).toLocaleDateString() : '—';
|
||||
const lastUsed = t.last_used_at ? new Date(t.last_used_at).toLocaleDateString() : 'Never';
|
||||
const expires = t.expires_at ? new Date(t.expires_at).toLocaleDateString() : 'Never';
|
||||
const isExpired = t.expires_at && new Date(t.expires_at) < new Date();
|
||||
return `
|
||||
<div class="token-card">
|
||||
<div class="token-card-icon">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" width="16" height="16"><circle cx="6" cy="10" r="3.5"/><path d="M8.7 7.3L13 3M11.5 3.5l1.5 1.5M13.5 2.5l1 1"/></svg>
|
||||
</div>
|
||||
<div class="token-card-body">
|
||||
<div class="token-card-name">${esc(t.name)}</div>
|
||||
<div class="token-card-meta">
|
||||
<span class="token-prefix">${esc(t.token_prefix)}…</span>
|
||||
· Created ${created}
|
||||
· Last used: ${lastUsed}
|
||||
· Expires: ${isExpired ? '<span style="color:var(--status-red)">Expired</span>' : expires}
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-danger btn-sm" onclick="confirmRevoke('${t.id}','${esc(t.name)}')">Revoke</button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
function rng(seed) { let x = Math.sin(seed) * 10000; return x - Math.floor(x); }
|
||||
const series = days.map((d, i) => {
|
||||
const baseline = 8000 + realAssets * 18 + realJobs * 12;
|
||||
const wk = (d.getDay() === 0 || d.getDay() === 6) ? 0.55 : 1.0;
|
||||
const wave = 1 + 0.45 * Math.sin(i * 0.9) + 0.18 * Math.cos(i * 1.7);
|
||||
const noise = 0.8 + 0.4 * rng(i * 13 + 7);
|
||||
const total = Math.round(baseline * wk * wave * noise);
|
||||
const ingest = Math.round(total * (0.35 + 0.05 * rng(i * 3 + 1)));
|
||||
const recorders = Math.round(total * (0.30 + 0.04 * rng(i * 5 + 2)));
|
||||
const render = Math.round(total * (0.20 + 0.04 * rng(i * 7 + 3)));
|
||||
const overage = Math.max(0, total - ingest - recorders - render);
|
||||
return { d, ingest, recorders, render, overage, total };
|
||||
|
||||
function openNewTokenPanel() {
|
||||
document.getElementById('tokenName').value = '';
|
||||
document.getElementById('tokenExpiry').value = '';
|
||||
document.getElementById('tokenPanel').classList.add('open');
|
||||
document.getElementById('tokenOverlay').classList.add('open');
|
||||
}
|
||||
|
||||
function closeNewTokenPanel() {
|
||||
document.getElementById('tokenPanel').classList.remove('open');
|
||||
document.getElementById('tokenOverlay').classList.remove('open');
|
||||
}
|
||||
|
||||
async function createNewToken() {
|
||||
const name = document.getElementById('tokenName').value.trim();
|
||||
if (!name) { toast('Token name required', '', 'warning'); return; }
|
||||
|
||||
const expires_in_days = document.getElementById('tokenExpiry').value || null;
|
||||
const btn = document.getElementById('createTokenBtn');
|
||||
btn.disabled = true;
|
||||
|
||||
const r = await createToken({ name, expires_in_days: expires_in_days ? parseInt(expires_in_days) : null });
|
||||
btn.disabled = false;
|
||||
|
||||
if (r.success) {
|
||||
closeNewTokenPanel();
|
||||
latestToken = r.data.token;
|
||||
document.getElementById('newTokenValue').textContent = latestToken;
|
||||
document.getElementById('newTokenBanner').style.display = 'block';
|
||||
document.getElementById('newTokenBanner').scrollIntoView({ behavior: 'smooth' });
|
||||
toast('Token created', name, 'success');
|
||||
loadTokens();
|
||||
} else {
|
||||
toast('Failed to create token', r.error, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmRevoke(id, name) {
|
||||
if (!confirm(`Revoke token "${name}"? Any scripts using it will stop working.`)) return;
|
||||
const r = await revokeToken(id);
|
||||
if (r.success) { toast('Token revoked', name, 'success'); loadTokens(); }
|
||||
else toast('Failed to revoke token', r.error, 'error');
|
||||
}
|
||||
|
||||
function copyToken() {
|
||||
if (!latestToken) return;
|
||||
navigator.clipboard.writeText(latestToken).then(() => {
|
||||
toast('Copied to clipboard', '', 'success');
|
||||
}).catch(() => {
|
||||
toast('Copy failed — select and copy manually', '', 'warning');
|
||||
});
|
||||
const max = Math.max(...series.map(s => s.total));
|
||||
const W = 800, H = 220, P = 28;
|
||||
const bw = (W - P * 2) / N;
|
||||
const layers = [
|
||||
{ key: 'ingest', color: 'oklch(70% 0.18 200)' },
|
||||
{ key: 'recorders', color: 'oklch(62% 0.15 145)' },
|
||||
{ key: 'render', color: 'oklch(70% 0.18 80)' },
|
||||
{ key: 'overage', color: 'oklch(62% 0.22 25)' },
|
||||
];
|
||||
const bars = series.map((s, i) => {
|
||||
const x = P + i * bw + 4;
|
||||
let yAcc = H - P;
|
||||
const stack = layers.map(l => {
|
||||
const v = s[l.key] || 0;
|
||||
const h = (v / max) * (H - P * 2);
|
||||
yAcc -= h;
|
||||
return '<rect x="' + x + '" y="' + yAcc + '" width="' + (bw - 8) + '" height="' + h + '" fill="' + l.color + '" opacity="0.92" rx="1"/>';
|
||||
}).join('');
|
||||
const label = s.d.toLocaleDateString('en', { month: 'short', day: 'numeric' });
|
||||
const lbl = i % 2 === 0 ? '<text x="' + (x + (bw-8)/2) + '" y="' + (H - 8) + '" text-anchor="middle" font-size="10" fill="oklch(55% 0.04 215)" font-family="var(--font-mono)">' + label + '</text>' : '';
|
||||
return stack + lbl;
|
||||
}).join('');
|
||||
let grid = '';
|
||||
for (let k = 0; k <= 4; k++) {
|
||||
const y = P + (H - P * 2) * (k / 4);
|
||||
grid += '<line x1="' + P + '" y1="' + y + '" x2="' + (W - P + 12) + '" y2="' + y + '" stroke="oklch(35% 0.06 215 / 0.25)" stroke-width="0.5"/>';
|
||||
const tick = Math.round(max * (1 - k / 4));
|
||||
grid += '<text x="' + (W - P + 16) + '" y="' + (y + 3) + '" font-size="9" fill="oklch(50% 0.04 215)" font-family="var(--font-mono)">' + tick.toLocaleString() + '</text>';
|
||||
}
|
||||
document.getElementById('burnChart').innerHTML = grid + bars;
|
||||
const mtd = series.reduce((a, s) => a + s.total, 0);
|
||||
const eom = Math.round(mtd * 2.3);
|
||||
const peakIdx = series.reduce((max, s, i, arr) => s.total > arr[max].total ? i : max, 0);
|
||||
const tvm = (0.92 + 0.6 * rng(Date.now() / 60000 | 0)).toFixed(2);
|
||||
document.getElementById('statMtd').textContent = mtd.toLocaleString();
|
||||
document.getElementById('statMtdDelta').textContent = '+' + Math.round(rng(1) * 40 + 15) + '% vs prior period';
|
||||
document.getElementById('statEom').textContent = eom.toLocaleString();
|
||||
document.getElementById('statEomDelta').textContent = '+340% over plan';
|
||||
document.getElementById('statTvm').textContent = tvm + 'x';
|
||||
document.getElementById('statTvmTrend').textContent = parseFloat(tvm) > 1.2 ? 'spiking' : 'stable';
|
||||
document.getElementById('statPeak').textContent = series[peakIdx].total.toLocaleString();
|
||||
document.getElementById('statPeakDay').textContent = series[peakIdx].d.toLocaleDateString('en', { weekday: 'short' });
|
||||
}
|
||||
renderBurnChart();
|
||||
|
||||
function toast(title, msg, type = 'info') {
|
||||
const icons = {
|
||||
success: `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="6.5"/><path d="M5 8l2 2 4-4"/></svg>`,
|
||||
error: `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="6.5"/><path d="M8 5v4M8 11v.5"/></svg>`,
|
||||
warning: `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 2L1 14h14L8 2z"/><path d="M8 7v3M8 12v.5"/></svg>`,
|
||||
info: `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="6.5"/><path d="M8 7v5M8 5v.5"/></svg>`,
|
||||
};
|
||||
const el = document.createElement('div');
|
||||
el.className = `toast toast--${type}`;
|
||||
el.innerHTML = `<div class="toast-icon">${icons[type]||icons.info}</div><div class="toast-body"><div class="toast-title">${esc(title)}</div>${msg?`<div class="toast-msg">${esc(msg)}</div>`:''}</div>`;
|
||||
document.getElementById('toastContainer').appendChild(el);
|
||||
setTimeout(() => el.remove(), 4000);
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
if (!s) return '';
|
||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
</script>
|
||||
<script src="js/auth-guard.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -461,5 +461,6 @@
|
|||
return (bytes/1024/1024/1024).toFixed(2) + ' GB';
|
||||
}
|
||||
</script>
|
||||
<script src="js/auth-guard.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
572
services/web-ui/public/users.html
Normal file
572
services/web-ui/public/users.html
Normal file
|
|
@ -0,0 +1,572 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Users — Wild Dragon</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="css/common.css">
|
||||
<style>
|
||||
.users-shell {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.tab-content { display: none; }
|
||||
.tab-content.active { display: flex; flex-direction: column; flex: 1; overflow: hidden; }
|
||||
.table-area {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--sp-6);
|
||||
}
|
||||
.table-area::-webkit-scrollbar { width: 5px; }
|
||||
.table-area::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 3px; }
|
||||
.section-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--sp-4);
|
||||
}
|
||||
.section-title {
|
||||
font-size: var(--text-md);
|
||||
font-weight: 500;
|
||||
}
|
||||
.action-row {
|
||||
display: flex;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
.member-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-1);
|
||||
margin-top: var(--sp-2);
|
||||
}
|
||||
.member-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-1);
|
||||
padding: 2px 8px 2px 6px;
|
||||
border-radius: 100px;
|
||||
font-size: var(--text-xs);
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.member-chip button {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.member-chip button:hover { color: var(--status-red); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="shell">
|
||||
|
||||
<!-- Sidebar -->
|
||||
<nav class="sidebar" aria-label="Main navigation">
|
||||
<div class="sidebar-brand">
|
||||
<div class="sidebar-brand-mark">
|
||||
<svg viewBox="0 0 16 16" fill="currentColor" width="12" height="12"><path d="M8 1L2 5v6l6 4 6-4V5L8 1zm0 2.2L12 6v4l-4 2.7L4 10V6l4-2.8z"/></svg>
|
||||
</div>
|
||||
<span class="sidebar-brand-name">Wild Dragon</span>
|
||||
</div>
|
||||
<nav class="sidebar-nav">
|
||||
<a href="index.html" class="nav-item">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="1" width="6" height="6" rx="1"/><rect x="9" y="1" width="6" height="6" rx="1"/><rect x="1" y="9" width="6" height="6" rx="1"/><rect x="9" y="9" width="6" height="6" rx="1"/></svg>
|
||||
Library
|
||||
</a>
|
||||
<a href="upload.html" class="nav-item">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 11V3M5 6l3-3 3 3"/><path d="M2 13h12"/></svg>
|
||||
Ingest
|
||||
</a>
|
||||
<a href="recorders.html" class="nav-item">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="4" width="10" height="8" rx="1"/><path d="M11 7l4-2v6l-4-2"/></svg>
|
||||
Recorders
|
||||
</a>
|
||||
<a href="capture.html" class="nav-item">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="3"/><circle cx="8" cy="8" r="6.5"/></svg>
|
||||
Capture
|
||||
</a>
|
||||
<a href="jobs.html" class="nav-item">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 4h12M2 8h8M2 12h5"/></svg>
|
||||
Jobs
|
||||
</a>
|
||||
<div class="sidebar-section-label">Admin</div>
|
||||
<a href="settings.html" class="nav-item">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="2.5"/><path d="M8 1.5v1M8 13.5v1M1.5 8h1M13.5 8h1M3.2 3.2l.7.7M12.1 12.1l.7.7M12.1 3.9l-.7.7M3.9 12.1l-.7.7"/></svg>
|
||||
Settings
|
||||
</a>
|
||||
<a href="users.html" class="nav-item active">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="6" cy="5" r="2.5"/><path d="M1 13c0-2.8 2.2-5 5-5s5 2.2 5 5"/><circle cx="12" cy="5" r="2"/><path d="M15 12c0-1.9-1.3-3.5-3-4"/></svg>
|
||||
Users
|
||||
</a>
|
||||
<a href="tokens.html" class="nav-item">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="6" cy="10" r="3.5"/><path d="M8.7 7.3L13 3M11.5 3.5l1.5 1.5M13.5 2.5l1 1"/></svg>
|
||||
Tokens
|
||||
</a>
|
||||
</nav>
|
||||
<div class="sidebar-footer">
|
||||
<div class="sidebar-user">
|
||||
<div class="sidebar-user-avatar" id="userAvatar">?</div>
|
||||
<div class="sidebar-user-info">
|
||||
<div class="sidebar-user-name" id="userName">—</div>
|
||||
<div class="sidebar-user-role" id="userRole"></div>
|
||||
</div>
|
||||
<button class="btn btn-ghost" id="logoutBtn" title="Sign out" style="padding:0;width:24px;height:24px;flex-shrink:0;">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" width="14" height="14"><path d="M10 8H3M6 5l-3 3 3 3"/><path d="M7 3h5a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1H7"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Main -->
|
||||
<div class="main">
|
||||
<header class="topbar">
|
||||
<div class="topbar-left">
|
||||
<span class="page-title">Users & Groups</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="users-shell">
|
||||
<!-- Tabs -->
|
||||
<div class="tabs" style="padding:0 var(--sp-6);background:var(--bg-panel);border-bottom:1px solid var(--border);flex-shrink:0;">
|
||||
<button class="tab active" onclick="switchTab('users',this)">Users</button>
|
||||
<button class="tab" onclick="switchTab('groups',this)">Groups</button>
|
||||
</div>
|
||||
|
||||
<!-- Users tab -->
|
||||
<div class="tab-content active" id="tab-users">
|
||||
<div class="table-area">
|
||||
<div class="section-toolbar">
|
||||
<span class="section-title" id="userCount">Users</span>
|
||||
<button class="btn btn-primary btn-sm" onclick="openUserPanel()">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 2v12M2 8h12"/></svg>
|
||||
New user
|
||||
</button>
|
||||
</div>
|
||||
<table class="data-table" id="usersTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Display name</th>
|
||||
<th>Role</th>
|
||||
<th>Groups</th>
|
||||
<th>Created</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="usersTbody">
|
||||
<tr><td colspan="6" style="text-align:center;color:var(--text-tertiary);padding:var(--sp-8)">Loading…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Groups tab -->
|
||||
<div class="tab-content" id="tab-groups">
|
||||
<div class="table-area">
|
||||
<div class="section-toolbar">
|
||||
<span class="section-title" id="groupCount">Groups</span>
|
||||
<button class="btn btn-primary btn-sm" onclick="openGroupPanel()">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 2v12M2 8h12"/></svg>
|
||||
New group
|
||||
</button>
|
||||
</div>
|
||||
<table class="data-table" id="groupsTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Description</th>
|
||||
<th>Members</th>
|
||||
<th>Created</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="groupsTbody">
|
||||
<tr><td colspan="5" style="text-align:center;color:var(--text-tertiary);padding:var(--sp-8)">Loading…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User slide panel -->
|
||||
<div class="slide-overlay" id="userOverlay" onclick="closeUserPanel()"></div>
|
||||
<div class="slide-panel" id="userPanel">
|
||||
<div class="slide-panel-header">
|
||||
<span class="slide-panel-title" id="userPanelTitle">New user</span>
|
||||
<button class="btn btn-ghost btn-sm" onclick="closeUserPanel()" style="padding:0;width:28px;height:28px;">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 3l10 10M13 3L3 13"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="slide-panel-body">
|
||||
<input type="hidden" id="editUserId">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="uUsername">Username</label>
|
||||
<input type="text" id="uUsername" placeholder="e.g. jsmith">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="uDisplayName">Display name</label>
|
||||
<input type="text" id="uDisplayName" placeholder="e.g. Jane Smith">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="uRole">Role</label>
|
||||
<select id="uRole">
|
||||
<option value="editor">Editor</option>
|
||||
<option value="viewer">Viewer</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="uPassword" id="uPasswordLabel">Password</label>
|
||||
<input type="password" id="uPassword" placeholder="Min 8 characters">
|
||||
<div class="form-hint" id="uPasswordHint"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="slide-panel-footer">
|
||||
<button class="btn btn-ghost" onclick="closeUserPanel()">Cancel</button>
|
||||
<button class="btn btn-primary" id="saveUserBtn" onclick="saveUser()">Create user</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Group slide panel -->
|
||||
<div class="slide-overlay" id="groupOverlay" onclick="closeGroupPanel()"></div>
|
||||
<div class="slide-panel" id="groupPanel">
|
||||
<div class="slide-panel-header">
|
||||
<span class="slide-panel-title" id="groupPanelTitle">New group</span>
|
||||
<button class="btn btn-ghost btn-sm" onclick="closeGroupPanel()" style="padding:0;width:28px;height:28px;">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 3l10 10M13 3L3 13"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="slide-panel-body">
|
||||
<input type="hidden" id="editGroupId">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="gName">Group name</label>
|
||||
<input type="text" id="gName" placeholder="e.g. News Team">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="gDescription">Description</label>
|
||||
<textarea id="gDescription" rows="2" placeholder="Optional description"></textarea>
|
||||
</div>
|
||||
<div class="form-group" id="gMembersSection" style="display:none;">
|
||||
<label class="form-label">Members</label>
|
||||
<div class="member-chips" id="memberChips"></div>
|
||||
<select id="addMemberSelect" style="margin-top:var(--sp-2);">
|
||||
<option value="">Add member…</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="slide-panel-footer">
|
||||
<button class="btn btn-ghost" onclick="closeGroupPanel()">Cancel</button>
|
||||
<button class="btn btn-primary" id="saveGroupBtn" onclick="saveGroup()">Create group</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toast-container" id="toastContainer" aria-live="polite"></div>
|
||||
|
||||
<script src="js/api.js"></script>
|
||||
<script>
|
||||
let allUsers = [], allGroups = [], currentGroupMembers = [];
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadUsers();
|
||||
loadGroups();
|
||||
});
|
||||
|
||||
// ── Tabs ──────────────────────────────────────────────────
|
||||
function switchTab(name, btn) {
|
||||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
document.getElementById('tab-' + name).classList.add('active');
|
||||
}
|
||||
|
||||
// ── Load users ────────────────────────────────────────────
|
||||
async function loadUsers() {
|
||||
const r = await getUsers();
|
||||
if (!r.success) { toast('Failed to load users', r.error, 'error'); return; }
|
||||
allUsers = r.data;
|
||||
renderUsers();
|
||||
}
|
||||
|
||||
function renderUsers() {
|
||||
const tbody = document.getElementById('usersTbody');
|
||||
document.getElementById('userCount').textContent = `${allUsers.length} user${allUsers.length !== 1 ? 's' : ''}`;
|
||||
if (!allUsers.length) {
|
||||
tbody.innerHTML = `<tr><td colspan="6" style="text-align:center;color:var(--text-tertiary);padding:var(--sp-8)">No users yet</td></tr>`;
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = allUsers.map(u => `
|
||||
<tr>
|
||||
<td><code style="font-size:var(--text-xs)">${esc(u.username)}</code></td>
|
||||
<td>${esc(u.display_name || '—')}</td>
|
||||
<td><span class="badge badge-${u.role}">${esc(u.role)}</span></td>
|
||||
<td><span class="text-tertiary text-xs">${u.group_count || 0} group${u.group_count !== 1 ? 's' : ''}</span></td>
|
||||
<td class="text-xs text-tertiary">${u.created_at ? new Date(u.created_at).toLocaleDateString() : '—'}</td>
|
||||
<td>
|
||||
<div style="display:flex;gap:var(--sp-1);justify-content:flex-end;">
|
||||
<button class="btn btn-ghost btn-sm" onclick="editUser('${u.id}')">Edit</button>
|
||||
<button class="btn btn-danger btn-sm" onclick="confirmDeleteUser('${u.id}','${esc(u.username)}')">Delete</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
|
||||
// ── User panel ────────────────────────────────────────────
|
||||
function openUserPanel(userId) {
|
||||
document.getElementById('editUserId').value = '';
|
||||
document.getElementById('uUsername').value = '';
|
||||
document.getElementById('uDisplayName').value = '';
|
||||
document.getElementById('uRole').value = 'editor';
|
||||
document.getElementById('uPassword').value = '';
|
||||
document.getElementById('uUsername').disabled = false;
|
||||
document.getElementById('userPanelTitle').textContent = 'New user';
|
||||
document.getElementById('saveUserBtn').textContent = 'Create user';
|
||||
document.getElementById('uPasswordLabel').textContent = 'Password';
|
||||
document.getElementById('uPasswordHint').textContent = '';
|
||||
document.getElementById('userPanel').classList.add('open');
|
||||
document.getElementById('userOverlay').classList.add('open');
|
||||
}
|
||||
|
||||
function editUser(id) {
|
||||
const u = allUsers.find(x => x.id === id);
|
||||
if (!u) return;
|
||||
document.getElementById('editUserId').value = u.id;
|
||||
document.getElementById('uUsername').value = u.username;
|
||||
document.getElementById('uUsername').disabled = true;
|
||||
document.getElementById('uDisplayName').value = u.display_name || '';
|
||||
document.getElementById('uRole').value = u.role;
|
||||
document.getElementById('uPassword').value = '';
|
||||
document.getElementById('userPanelTitle').textContent = 'Edit user';
|
||||
document.getElementById('saveUserBtn').textContent = 'Save changes';
|
||||
document.getElementById('uPasswordLabel').textContent = 'New password';
|
||||
document.getElementById('uPasswordHint').textContent = 'Leave blank to keep existing password';
|
||||
document.getElementById('userPanel').classList.add('open');
|
||||
document.getElementById('userOverlay').classList.add('open');
|
||||
}
|
||||
|
||||
function closeUserPanel() {
|
||||
document.getElementById('userPanel').classList.remove('open');
|
||||
document.getElementById('userOverlay').classList.remove('open');
|
||||
}
|
||||
|
||||
async function saveUser() {
|
||||
const id = document.getElementById('editUserId').value;
|
||||
const username = document.getElementById('uUsername').value.trim();
|
||||
const display_name = document.getElementById('uDisplayName').value.trim();
|
||||
const role = document.getElementById('uRole').value;
|
||||
const password = document.getElementById('uPassword').value;
|
||||
|
||||
if (!id && !username) { toast('Username required', '', 'warning'); return; }
|
||||
if (!id && !password) { toast('Password required for new user', '', 'warning'); return; }
|
||||
|
||||
const btn = document.getElementById('saveUserBtn');
|
||||
btn.disabled = true;
|
||||
|
||||
let r;
|
||||
if (id) {
|
||||
const payload = { display_name, role };
|
||||
if (password) payload.password = password;
|
||||
r = await updateUser(id, payload);
|
||||
} else {
|
||||
r = await createUser({ username, display_name, role, password });
|
||||
}
|
||||
|
||||
btn.disabled = false;
|
||||
if (r.success) {
|
||||
toast(id ? 'User updated' : 'User created', '', 'success');
|
||||
closeUserPanel();
|
||||
loadUsers();
|
||||
} else {
|
||||
toast('Failed to save user', r.error, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDeleteUser(id, name) {
|
||||
if (!confirm(`Delete user "${name}"? This cannot be undone.`)) return;
|
||||
const r = await deleteUser(id);
|
||||
if (r.success) { toast('User deleted', '', 'success'); loadUsers(); }
|
||||
else toast('Failed to delete user', r.error, 'error');
|
||||
}
|
||||
|
||||
// ── Load groups ───────────────────────────────────────────
|
||||
async function loadGroups() {
|
||||
const r = await getGroups();
|
||||
if (!r.success) { toast('Failed to load groups', r.error, 'error'); return; }
|
||||
allGroups = r.data;
|
||||
renderGroups();
|
||||
}
|
||||
|
||||
function renderGroups() {
|
||||
const tbody = document.getElementById('groupsTbody');
|
||||
document.getElementById('groupCount').textContent = `${allGroups.length} group${allGroups.length !== 1 ? 's' : ''}`;
|
||||
if (!allGroups.length) {
|
||||
tbody.innerHTML = `<tr><td colspan="5" style="text-align:center;color:var(--text-tertiary);padding:var(--sp-8)">No groups yet</td></tr>`;
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = allGroups.map(g => `
|
||||
<tr>
|
||||
<td style="font-weight:500">${esc(g.name)}</td>
|
||||
<td class="text-secondary text-sm">${esc(g.description || '—')}</td>
|
||||
<td class="text-xs text-tertiary">${g.member_count || 0} member${g.member_count !== 1 ? 's' : ''}</td>
|
||||
<td class="text-xs text-tertiary">${g.created_at ? new Date(g.created_at).toLocaleDateString() : '—'}</td>
|
||||
<td>
|
||||
<div style="display:flex;gap:var(--sp-1);justify-content:flex-end;">
|
||||
<button class="btn btn-ghost btn-sm" onclick="editGroup('${g.id}')">Edit</button>
|
||||
<button class="btn btn-danger btn-sm" onclick="confirmDeleteGroup('${g.id}','${esc(g.name)}')">Delete</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
|
||||
// ── Group panel ───────────────────────────────────────────
|
||||
function openGroupPanel() {
|
||||
document.getElementById('editGroupId').value = '';
|
||||
document.getElementById('gName').value = '';
|
||||
document.getElementById('gDescription').value = '';
|
||||
document.getElementById('gMembersSection').style.display = 'none';
|
||||
document.getElementById('groupPanelTitle').textContent = 'New group';
|
||||
document.getElementById('saveGroupBtn').textContent = 'Create group';
|
||||
document.getElementById('groupPanel').classList.add('open');
|
||||
document.getElementById('groupOverlay').classList.add('open');
|
||||
}
|
||||
|
||||
async function editGroup(id) {
|
||||
const g = allGroups.find(x => x.id === id);
|
||||
if (!g) return;
|
||||
document.getElementById('editGroupId').value = g.id;
|
||||
document.getElementById('gName').value = g.name;
|
||||
document.getElementById('gDescription').value = g.description || '';
|
||||
document.getElementById('groupPanelTitle').textContent = 'Edit group';
|
||||
document.getElementById('saveGroupBtn').textContent = 'Save changes';
|
||||
document.getElementById('groupPanel').classList.add('open');
|
||||
document.getElementById('groupOverlay').classList.add('open');
|
||||
|
||||
// Load members
|
||||
document.getElementById('gMembersSection').style.display = 'block';
|
||||
const mr = await getGroupMembers(id);
|
||||
currentGroupMembers = mr.success ? mr.data : [];
|
||||
renderMemberChips(id);
|
||||
|
||||
// Populate add-member dropdown
|
||||
const sel = document.getElementById('addMemberSelect');
|
||||
const memberIds = new Set(currentGroupMembers.map(m => m.id));
|
||||
sel.innerHTML = '<option value="">Add member…</option>' +
|
||||
allUsers.filter(u => !memberIds.has(u.id))
|
||||
.map(u => `<option value="${u.id}">${esc(u.display_name || u.username)}</option>`)
|
||||
.join('');
|
||||
sel.onchange = async () => {
|
||||
if (!sel.value) return;
|
||||
await addGroupMember(id, sel.value);
|
||||
const mr2 = await getGroupMembers(id);
|
||||
currentGroupMembers = mr2.success ? mr2.data : [];
|
||||
renderMemberChips(id);
|
||||
// Update dropdown
|
||||
const memberIds2 = new Set(currentGroupMembers.map(m => m.id));
|
||||
sel.innerHTML = '<option value="">Add member…</option>' +
|
||||
allUsers.filter(u => !memberIds2.has(u.id))
|
||||
.map(u => `<option value="${u.id}">${esc(u.display_name || u.username)}</option>`)
|
||||
.join('');
|
||||
loadGroups();
|
||||
};
|
||||
}
|
||||
|
||||
function renderMemberChips(groupId) {
|
||||
const container = document.getElementById('memberChips');
|
||||
if (!currentGroupMembers.length) {
|
||||
container.innerHTML = `<span class="text-xs text-tertiary">No members yet</span>`;
|
||||
return;
|
||||
}
|
||||
container.innerHTML = currentGroupMembers.map(m => `
|
||||
<span class="member-chip">
|
||||
${esc(m.display_name || m.username)}
|
||||
<button onclick="removeMember('${groupId}','${m.id}')" title="Remove">
|
||||
<svg viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.5" width="10" height="10"><path d="M2 2l6 6M8 2L2 8"/></svg>
|
||||
</button>
|
||||
</span>`).join('');
|
||||
}
|
||||
|
||||
async function removeMember(groupId, userId) {
|
||||
await removeGroupMember(groupId, userId);
|
||||
const mr = await getGroupMembers(groupId);
|
||||
currentGroupMembers = mr.success ? mr.data : [];
|
||||
renderMemberChips(groupId);
|
||||
const memberIds = new Set(currentGroupMembers.map(m => m.id));
|
||||
const sel = document.getElementById('addMemberSelect');
|
||||
sel.innerHTML = '<option value="">Add member…</option>' +
|
||||
allUsers.filter(u => !memberIds.has(u.id))
|
||||
.map(u => `<option value="${u.id}">${esc(u.display_name || u.username)}</option>`)
|
||||
.join('');
|
||||
loadGroups();
|
||||
}
|
||||
|
||||
function closeGroupPanel() {
|
||||
document.getElementById('groupPanel').classList.remove('open');
|
||||
document.getElementById('groupOverlay').classList.remove('open');
|
||||
currentGroupMembers = [];
|
||||
}
|
||||
|
||||
async function saveGroup() {
|
||||
const id = document.getElementById('editGroupId').value;
|
||||
const name = document.getElementById('gName').value.trim();
|
||||
const description = document.getElementById('gDescription').value.trim();
|
||||
if (!name) { toast('Group name required', '', 'warning'); return; }
|
||||
|
||||
const btn = document.getElementById('saveGroupBtn');
|
||||
btn.disabled = true;
|
||||
|
||||
const r = id
|
||||
? await updateGroup(id, { name, description })
|
||||
: await createGroup({ name, description });
|
||||
|
||||
btn.disabled = false;
|
||||
if (r.success) {
|
||||
toast(id ? 'Group updated' : 'Group created', name, 'success');
|
||||
closeGroupPanel();
|
||||
loadGroups();
|
||||
} else {
|
||||
toast('Failed to save group', r.error, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDeleteGroup(id, name) {
|
||||
if (!confirm(`Delete group "${name}"?`)) return;
|
||||
const r = await deleteGroup(id);
|
||||
if (r.success) { toast('Group deleted', '', 'success'); loadGroups(); }
|
||||
else toast('Failed to delete group', r.error, 'error');
|
||||
}
|
||||
|
||||
// ── Toast ─────────────────────────────────────────────────
|
||||
function toast(title, msg, type = 'info') {
|
||||
const icons = {
|
||||
success: `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="6.5"/><path d="M5 8l2 2 4-4"/></svg>`,
|
||||
error: `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="6.5"/><path d="M8 5v4M8 11v.5"/></svg>`,
|
||||
warning: `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 2L1 14h14L8 2z"/><path d="M8 7v3M8 12v.5"/></svg>`,
|
||||
info: `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="6.5"/><path d="M8 7v5M8 5v.5"/></svg>`,
|
||||
};
|
||||
const el = document.createElement('div');
|
||||
el.className = `toast toast--${type}`;
|
||||
el.innerHTML = `<div class="toast-icon">${icons[type]||icons.info}</div><div class="toast-body"><div class="toast-title">${esc(title)}</div>${msg?`<div class="toast-msg">${esc(msg)}</div>`:''}</div>`;
|
||||
document.getElementById('toastContainer').appendChild(el);
|
||||
setTimeout(() => el.remove(), 4000);
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
if (!s) return '';
|
||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
</script>
|
||||
<script src="js/auth-guard.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in a new issue