diff --git a/docs/superpowers/plans/2026-05-18-nle-editor.md b/docs/superpowers/plans/2026-05-18-nle-editor.md new file mode 100644 index 0000000..1aa72f5 --- /dev/null +++ b/docs/superpowers/plans/2026-05-18-nle-editor.md @@ -0,0 +1,2246 @@ +# NLE Editor (Phase 1) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a Premiere-style NLE editor page (`editor.html`) with source monitor (in/out marking), DOM-based timeline (Select + Razor tools, undo/redo), program monitor (virtual clip-by-clip playback), and EDL export — backed by new `sequences` + `sequence_clips` DB tables and a REST API. + +**Architecture:** New `editor.html` page loads three shared JS modules (`timecode.js`, `timeline.js`, plus existing `api.js`) as ` + + + + + + +``` + +- [ ] **Step 2: Commit** + +```bash +git add services/web-ui/public/editor.html +git commit -m "feat(ui): add NLE editor page (editor.html)" +``` + +--- + +## Task 8: Library Integration + +**Files:** +- Modify: `services/web-ui/public/index.html` + +- [ ] **Step 1: Add Editor nav link to the sidebar** — find the ` + + Editor + +``` + +- [ ] **Step 2: Add "Open in Editor" button to asset cards** — in `index.html`, find the `deleteAssetPrompt` button inside `card.innerHTML`. Change the `asset-actions` div to add a second button: + +```html +
+ + +
+``` + +- [ ] **Step 3: Add the `openInEditor` function** in `index.html`'s ` + diff --git a/services/web-ui/public/index.html b/services/web-ui/public/index.html index dba95b1..1a4c515 100644 --- a/services/web-ui/public/index.html +++ b/services/web-ui/public/index.html @@ -831,5 +831,6 @@ return `${m}:${String(s).padStart(2,'0')}`; } + diff --git a/services/web-ui/public/jobs.html b/services/web-ui/public/jobs.html index 181ea11..82f2f2a 100644 --- a/services/web-ui/public/jobs.html +++ b/services/web-ui/public/jobs.html @@ -853,5 +853,6 @@ function showError(msg) { toast(msg, 'error'); } loadJobs(); + diff --git a/services/web-ui/public/js/api.js b/services/web-ui/public/js/api.js index a388335..d32a8c5 100644 --- a/services/web-ui/public/js/api.js +++ b/services/web-ui/public/js/api.js @@ -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' }); +} diff --git a/services/web-ui/public/js/auth-guard.js b/services/web-ui/public/js/auth-guard.js new file mode 100644 index 0000000..67851d8 --- /dev/null +++ b/services/web-ui/public/js/auth-guard.js @@ -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'; + }; + } +})(); diff --git a/services/web-ui/public/recorders.html b/services/web-ui/public/recorders.html index e6fd524..b0bff95 100644 --- a/services/web-ui/public/recorders.html +++ b/services/web-ui/public/recorders.html @@ -789,5 +789,6 @@ return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } + diff --git a/services/web-ui/public/settings.html b/services/web-ui/public/settings.html index cc51df0..c6a47a4 100644 --- a/services/web-ui/public/settings.html +++ b/services/web-ui/public/settings.html @@ -295,5 +295,6 @@ el.textContent = msg; } + diff --git a/services/web-ui/public/tokens.html b/services/web-ui/public/tokens.html index a387436..6f83885 100644 --- a/services/web-ui/public/tokens.html +++ b/services/web-ui/public/tokens.html @@ -3,703 +3,347 @@ - - Token Pricing — Z-AMPP + Tokens — Wild Dragon - + -
+ + -
+ +
- Token Pricing - / - Enterprise Compute Compliance Engine v4.7 + API Tokens
- +
-
- - -
- Z-AMPP Pricing -

Per-seat · Per-stream · Per-month
Per token.

-

Welcome to the future of broadcast media operations. Tokens are fungible compute credits that flexibly meter every action across the Platform™. Move faster. Pay precisely. Forecast nothing.

+
+
+

+ API tokens let scripts and integrations authenticate as you without using your password. + Tokens are shown once at creation — store them securely. + Use Authorization: Bearer <token> in your requests. +

- -
- -
-
Starter
-
$499 / mo
-
100,000 tokens · $4.99 / 1k
-
    -
  • 1 concurrent recorder
  • -
  • SD ingest (480p · 1.2× multiplier)
  • -
  • Standard support · email · 96h SLA
  • -
  • No HD codec access
  • -
  • No ProRes write
  • -
- + +