feat(premiere-plugin-uxp): v2.0.0 — UXP port replacing CEP for import

CEP `csInterface.evalScript` callback is broken in Premiere Pro 26.0.x —
nothing called from the panel ever returns, so importFiles deadlocks. Adobe's
path forward is UXP. This is the minimum viable port that restores the
Import Proxy / Import Hi-Res workflow.

Scope (v2.0.0):
- Connect to a Dragonflight server (URL + Bearer token; persisted)
- Asset library (search, refresh, grid with thumbnails)
- Import Proxy via streamed download → Project.importFiles
- Import Hi-Res via presigned S3 URL → Project.importFiles

Layout:
  manifest.json     UXP v5, host=premierepro, minVersion=26.0.0
  index.html        Panel shell
  styles.css        Mirrors web UI dark tokens
  src/ui.js         DOM helpers, toast, progress, formatting
  src/api.js        HTTP client (Bearer; manual redirect-follow drops auth
                    when hopping to a different host per UXP security policy)
  src/library.js    Asset grid render + selection
  src/import-flow.js  Streaming download (fs.createWriteStream) +
                      premierepro.Project.importFiles into rootBin
  src/main.js       Bootstrap, event wiring
  build/pack.mjs    Packs into .ccx; installs via UnifiedPluginInstallerAgent

Coexists with services/premiere-plugin/ (CEP) — keeps the CEP panel for any
features that still work there while running v2.0.0 for import. Future v2.x
will add live preview, conform, timeline export, settings.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Claude 2026-05-28 00:19:28 -04:00
parent 8b48f03f6b
commit 91e4691230
9 changed files with 929 additions and 0 deletions

View file

@ -0,0 +1,89 @@
# Dragonflight MAM — Premiere Pro UXP panel (v2.0.0)
The successor to the CEP panel at `services/premiere-plugin/`.
**Why this exists:** Premiere Pro 26.0.x broke `csInterface.evalScript`'s
return-value callback — it no longer fires. That deadlocks any CEP panel
that needs to call back into Premiere (importFiles, sequence access, etc).
Adobe's path forward is UXP; this panel is the minimum viable port that
restores the Import Proxy / Import Hi-Res workflow.
## Scope (v2.0.0)
In:
- Connect to a Dragonflight server (URL + Bearer token, persisted in localStorage)
- Browse the asset library (search, refresh, grid with thumbnails)
- **Import Proxy** — downloads the proxy MP4 and adds it to the active project
- **Import Hi-Res** — downloads the original from S3 (presigned URL) and imports
Out (carried over from the CEP panel later, as v2.x):
- Mount Live (SMB live preview)
- Conform / Auto-Relink / Batch Trim
- Timeline export
- Settings tabs (Account, Tokens, S3) — use the web UI for now
## Project layout
```
manifest.json UXP manifest v5 (host=premierepro, minVersion=26.0.0)
index.html Panel shell (vanilla HTML)
styles.css Mirrors the web UI's dark design tokens
src/
ui.js DOM helpers, toast, progress, formatting
api.js Dragonflight HTTP client (Bearer auth, redirect handling)
library.js Asset grid render + selection
import-flow.js Download (streamed) + Premiere importFiles
main.js Bootstrap, event wiring
icons/ DarkIcon.png + LightIcon.png (23x23, optional)
build/pack.mjs Pack into .ccx for install
```
## Build
Requires Node 18+.
```bash
cd services/premiere-plugin-uxp
node build/pack.mjs
# → build/dist/dragonflight-mam-2.0.0.ccx
```
(Uses system `zip`. On Windows hosts use Compress-Archive — error message
shows the exact command.)
## Install on Windows
Prerequisite: **Premiere Pro Preferences > Plugins > Enable developer mode**
must be toggled on (one-time, per machine). This is required for any UXP
plugin not distributed via Creative Cloud Marketplace.
```powershell
& "C:\Program Files\Common Files\Adobe\Adobe Desktop Common\RemoteComponents\UPI\UnifiedPluginInstallerAgent\UnifiedPluginInstallerAgent.exe" /install "C:\path\to\dragonflight-mam-2.0.0.ccx"
```
That writes to `%APPDATA%\Adobe\UXP\Plugins\External\net.wilddragon.dragonflight.uxp\` and registers the plugin with UPIA.
To uninstall:
```powershell
& "...\UnifiedPluginInstallerAgent.exe" /remove net.wilddragon.dragonflight.uxp
```
Open the panel in Premiere via **Window > Extensions > Dragonflight MAM**.
## Development loop (Adobe UXP Developer Tool)
For iterative dev without rebuilding+reinstalling:
1. Install **Adobe UXP Developer Tool** ("UDT") from Creative Cloud.
2. UDT → Add Plugin → point at this folder.
3. Load → opens the panel in Premiere with hot-reload.
## Known limits
- Adobe strips `Authorization` headers on cross-origin redirects. `import-flow.js`
uses `redirect: 'manual'` and drops the Bearer when hopping to a different
host (e.g. when the server 302s us to the S3 endpoint).
- `Project.importFiles()` returns a `boolean`, not the new `ProjectItem`. To
locate the imported item, `Import.locateImported(project, filename)` does a
rootItem.getItems() diff after import.
- UXP's `os.tmpdir()` resolves to Windows `%TEMP%` which is cleared by Disk
Cleanup. For long-lived downloads, persist explicitly elsewhere.

View file

@ -0,0 +1,62 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Dragonflight MAM</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<div id="root">
<!-- Connection bar (visible when disconnected) -->
<section id="connect-pane" class="pane">
<div class="brand">
<div class="brand-title">Dragonflight</div>
<div class="brand-tag">Wild Dragon Broadcast</div>
</div>
<label class="label" for="server-url">Server URL</label>
<input id="server-url" type="text" placeholder="https://dragonflight.live" />
<label class="label" for="api-token">API token</label>
<input id="api-token" type="password" placeholder="Bearer token from web UI" autocomplete="off" />
<button id="connect-btn" class="btn btn-primary" disabled>Connect</button>
<div id="connect-status" class="status muted"></div>
</section>
<!-- Connected: library + actions -->
<section id="library-pane" class="pane hidden">
<header class="connected-bar">
<span class="dot dot-ok"></span>
<span id="connected-host" class="connected-host"></span>
<button id="disconnect-btn" class="btn btn-link">Disconnect</button>
</header>
<div class="library-controls">
<input id="search-input" type="search" placeholder="Search assets…" />
<button id="refresh-btn" class="btn btn-icon" title="Refresh"></button>
</div>
<div id="asset-grid" class="asset-grid">
<div class="empty muted">Loading…</div>
</div>
<footer class="actions">
<div id="selected-info" class="selected-info muted">No asset selected</div>
<div class="action-row">
<button id="import-proxy-btn" class="btn btn-primary" disabled>Import Proxy</button>
<button id="import-hires-btn" class="btn btn-secondary" disabled>Hi-Res</button>
</div>
<div id="progress-row" class="progress-row hidden">
<div class="progress-bar"><div id="progress-fill"></div></div>
<div id="progress-label" class="progress-label"></div>
</div>
<div id="toast" class="toast hidden"></div>
</footer>
</section>
</div>
<script src="src/ui.js"></script>
<script src="src/api.js"></script>
<script src="src/library.js"></script>
<script src="src/import-flow.js"></script>
<script src="src/main.js"></script>
</body>
</html>

View file

@ -0,0 +1,31 @@
{
"manifestVersion": 5,
"id": "net.wilddragon.dragonflight.uxp",
"name": "Dragonflight MAM",
"version": "2.0.0",
"main": "index.html",
"host": {
"app": "premierepro",
"minVersion": "26.0.0"
},
"entrypoints": [
{
"type": "panel",
"id": "main",
"label": { "default": "Dragonflight MAM" },
"minimumSize": { "width": 320, "height": 360 },
"preferredDockedSize": { "width": 380, "height": 600 },
"preferredFloatingSize":{ "width": 420, "height": 720 }
}
],
"requiredPermissions": {
"localFileSystem": "fullAccess",
"network": {
"domains": [
"https://dragonflight.live",
"https://broadcastmgmt.cloud"
]
},
"allowCodeGenerationFromStrings": false
}
}

View file

@ -0,0 +1,104 @@
// Dragonflight API client. Wraps UXP's fetch() with the Bearer header and
// handles the cross-origin-redirect quirk (UXP strips Authorization across
// hosts as a security fix — we follow such redirects manually).
//
// Persists serverUrl + apiToken in localStorage so the panel reconnects on
// reopen.
(function () {
const API = {};
const LS_URL = 'df.uxp.serverUrl';
const LS_TOKEN = 'df.uxp.apiToken';
API.state = {
serverUrl: localStorage.getItem(LS_URL) || '',
apiToken: localStorage.getItem(LS_TOKEN) || '',
connected: false,
};
API.save = function () {
localStorage.setItem(LS_URL, API.state.serverUrl);
localStorage.setItem(LS_TOKEN, API.state.apiToken);
};
API.clear = function () {
API.state.serverUrl = '';
API.state.apiToken = '';
API.state.connected = false;
localStorage.removeItem(LS_URL);
localStorage.removeItem(LS_TOKEN);
};
function trimUrl(u) { return String(u || '').replace(/\/+$/, ''); }
// Core request. `url` may be a path (joined to serverUrl) or absolute.
// Auth header is added only for requests to our own serverUrl.
API.request = async function (urlOrPath, opts) {
opts = opts || {};
const isAbs = /^https?:\/\//i.test(urlOrPath);
const base = trimUrl(API.state.serverUrl);
const url = isAbs ? urlOrPath : (base + urlOrPath);
const headers = Object.assign({}, opts.headers || {});
if (!isAbs || url.indexOf(base) === 0) {
if (API.state.apiToken) headers['Authorization'] = 'Bearer ' + API.state.apiToken;
}
// UXP fetch supports standard options. Don't pass `credentials` — UXP
// doesn't ship a cookie jar and warns about unsupported values.
return fetch(url, Object.assign({}, opts, { headers }));
};
API.json = async function (path, opts) {
const r = await API.request(path, opts);
if (!r.ok) {
const text = await r.text().catch(() => '');
throw new Error('HTTP ' + r.status + (text ? ' — ' + text.slice(0, 200) : ''));
}
return r.json();
};
// GET /api/v1/auth/me — used as the connect probe. Returns 200 on a valid
// bearer, 401 otherwise.
API.connect = async function (serverUrl, apiToken) {
API.state.serverUrl = trimUrl(serverUrl);
API.state.apiToken = String(apiToken || '').trim();
if (!API.state.serverUrl || !API.state.apiToken) {
throw new Error('Server URL and API token are required');
}
const me = await API.json('/api/v1/auth/me');
API.state.connected = true;
API.save();
return me;
};
API.disconnect = function () {
API.clear();
};
// Asset list. The web UI calls /api/v1/assets?... — we mirror that.
API.listAssets = async function (query) {
const params = new URLSearchParams();
if (query) params.set('q', query);
params.set('limit', '60');
return API.json('/api/v1/assets?' + params.toString());
};
// Resolve a proxy stream URL → returns an absolute URL we can fetch.
// /stream returns { url: '/api/v1/assets/<id>/video', type: 'mp4', source }.
API.getProxyUrl = async function (assetId) {
const data = await API.json('/api/v1/assets/' + assetId + '/stream');
if (!data || !data.url) throw new Error('Asset has no proxy');
const u = data.url;
const abs = /^https?:\/\//i.test(u) ? u : trimUrl(API.state.serverUrl) + u;
return { url: abs, source: data.source || 'proxy' };
};
// Resolve a hi-res URL → returns a presigned S3 URL (off-host).
// /hires returns { url, filename, ext, file_size, type: 'hires' }.
API.getHiresInfo = async function (assetId) {
const data = await API.json('/api/v1/assets/' + assetId + '/hires');
if (!data || !data.url) throw new Error('Asset has no hi-res source');
return data;
};
window.API = API;
})();

View file

@ -0,0 +1,162 @@
// Download asset → import into the active Premiere project via UXP's
// `premierepro` module. This is what the CEP panel could no longer do
// (csInterface.evalScript callback was lost in PPro 26).
(function () {
const Import = {};
// Use UXP's Node-style fs for streaming writes (avoids buffering multi-GB
// files in memory) and the `os` module for the temp dir. Both are exposed
// when manifest declares "localFileSystem": "fullAccess".
const fs = require('fs');
const os = require('os');
const path = require('path');
// Pick a temp folder dragonflight downloads land in. Cleared on each
// download to keep disk usage bounded.
function tempPath(safeName) {
return path.join(os.tmpdir(), 'dragonflight-' + safeName);
}
// Stream the body of a fetch Response to a file on disk.
// Returns the absolute path. Reports progress via onProgress({ received, total }).
async function streamToFile(response, destPath, onProgress) {
const total = Number(response.headers.get('content-length') || 0);
const reader = response.body.getReader();
const out = fs.createWriteStream(destPath);
let received = 0;
// Wrap close in a Promise so we don't resolve before the OS finishes
// flushing — without this, importFiles can race the writer.
const closed = new Promise((resolve, reject) => {
out.on('finish', resolve);
out.on('error', reject);
});
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
if (!out.write(Buffer.from(value))) {
// Backpressure — wait for drain so memory stays flat.
await new Promise(r => out.once('drain', r));
}
received += value.byteLength;
if (onProgress) onProgress({ received, total });
}
} finally {
out.end();
}
await closed;
return destPath;
}
// Manual-follow fetch for redirects: UXP strips Authorization across
// origins (per Adobe security policy), so for any /api/... call that might
// 302 to S3 we follow the redirect ourselves and DROP the Bearer header
// on the next hop.
async function fetchFollow(url, opts) {
opts = opts || {};
let current = url;
let auth = (opts.headers || {})['Authorization'] || null;
for (let hop = 0; hop < 5; hop++) {
const r = await fetch(current, Object.assign({}, opts, { redirect: 'manual' }));
if (r.status >= 300 && r.status < 400 && r.headers.get('location')) {
const next = r.headers.get('location');
const absNext = /^https?:\/\//i.test(next) ? next : new URL(next, current).toString();
const sameHost = (new URL(absNext)).host === (new URL(current)).host;
opts = Object.assign({}, opts, {
headers: Object.assign({}, opts.headers || {}, sameHost ? {} : { Authorization: undefined }),
});
if (!sameHost) delete opts.headers.Authorization;
current = absNext;
continue;
}
return r;
}
throw new Error('Too many redirects');
}
// Find the new ProjectItem after importFiles. Premiere's UXP importFiles
// returns only a boolean — we diff the rootItem.getItems() snapshot.
// (Not currently used but available for callers that need the item.)
Import.locateImported = async function (project, filename) {
try {
const root = await project.getRootItem();
const items = await root.getItems();
for (const it of items) {
const name = await it.getName().catch(() => null);
if (name && name === filename) return it;
}
} catch (_) {}
return null;
};
// Talk to Premiere. Lazy-require so the module isn't loaded until the user
// actually triggers an import (faster panel boot, clearer error if UXP host
// is missing the API).
function ppro() {
if (Import._ppro) return Import._ppro;
try { Import._ppro = require('premierepro'); }
catch (e) { throw new Error('UXP premierepro module unavailable: ' + e.message); }
return Import._ppro;
}
// Hand a downloaded file off to Premiere.
Import.importIntoProject = async function (filePath) {
const P = ppro();
const project = await P.Project.getActiveProject();
if (!project) throw new Error('No active Premiere project. Open or create a project first.');
const root = await project.getRootItem();
// suppressUI=true, targetBin=root, asNumberedStills=false.
const ok = await project.importFiles([filePath], true, root, false);
if (!ok) throw new Error('Premiere refused to import the file.');
return true;
};
// ── Proxy import: /stream returns a same-host /video URL ──────────
Import.proxy = async function (asset) {
const safeName = UI.sanitizeFilename((asset.display_name || asset.filename || asset.id) + '.mp4');
const dest = tempPath(safeName);
UI.showProgress('Resolving proxy URL…', 4);
const { url } = await API.getProxyUrl(asset.id);
UI.showProgress('Downloading ' + safeName + '…', 10);
const r = await fetchFollow(url, { headers: { Authorization: 'Bearer ' + API.state.apiToken } });
if (!r.ok) throw new Error('Download HTTP ' + r.status);
await streamToFile(r, dest, ({ received, total }) => {
const pct = total ? 10 + (received / total) * 75 : 10;
UI.showProgress('Downloading ' + UI.formatBytes(received) + (total ? ' / ' + UI.formatBytes(total) : '') + '…', pct);
});
UI.showProgress('Importing into Premiere…', 92);
await Import.importIntoProject(dest);
UI.hideProgress();
UI.toast('Imported: ' + safeName, 'ok');
};
// ── Hi-Res import: /hires returns a presigned S3 URL on broadcastmgmt.cloud
Import.hires = async function (asset) {
UI.showProgress('Resolving hi-res URL…', 4);
const info = await API.getHiresInfo(asset.id);
const safeName = UI.sanitizeFilename(info.filename || (asset.display_name || asset.id) + '.' + (info.ext || 'mxf'));
const dest = tempPath(safeName);
UI.showProgress('Downloading ' + safeName + ' (' + UI.formatBytes(Number(info.file_size || 0)) + ')…', 8);
// info.url is presigned S3 — DO NOT add Bearer (would break signature).
const r = await fetchFollow(info.url, {});
if (!r.ok) throw new Error('Download HTTP ' + r.status);
await streamToFile(r, dest, ({ received, total }) => {
const pct = total ? 8 + (received / total) * 80 : 8;
UI.showProgress('Downloading ' + UI.formatBytes(received) + (total ? ' / ' + UI.formatBytes(total) : '') + '…', pct);
});
UI.showProgress('Importing into Premiere…', 92);
await Import.importIntoProject(dest);
UI.hideProgress();
UI.toast('Hi-res imported: ' + safeName, 'ok');
};
window.Import = Import;
})();

View file

@ -0,0 +1,105 @@
// Asset library rendering. Calls API.listAssets, renders a grid into
// #asset-grid, tracks the selected asset for the action buttons.
(function () {
const Library = {};
Library.state = { assets: [], selectedId: null };
Library.render = function () {
const grid = UI.$('#asset-grid');
grid.innerHTML = '';
if (!Library.state.assets.length) {
const e = document.createElement('div');
e.className = 'empty muted';
e.textContent = 'No assets';
grid.appendChild(e);
return;
}
for (const a of Library.state.assets) {
grid.appendChild(makeCard(a));
}
Library.syncActions();
};
function makeCard(asset) {
const card = document.createElement('div');
card.className = 'asset-card';
if (asset.id === Library.state.selectedId) card.classList.add('selected');
card.dataset.assetId = asset.id;
const thumbKey = asset.thumbnail_s3_key || asset.thumbnail || null;
const thumbUrl = thumbKey
? `${API.state.serverUrl}/api/v1/assets/${asset.id}/thumbnail?redirect=1`
: null;
if (thumbUrl) {
const img = document.createElement('img');
img.className = 'asset-thumb';
img.alt = asset.display_name || asset.filename || asset.id;
// UXP fetch supports cross-origin images; the redirect=1 query on
// /thumbnail tells the server to 302 to the presigned S3 URL.
img.src = thumbUrl;
img.onerror = () => { img.replaceWith(placeholder()); };
card.appendChild(img);
} else {
card.appendChild(placeholder());
}
const name = document.createElement('div');
name.className = 'asset-name';
name.textContent = asset.display_name || asset.filename || asset.id;
card.appendChild(name);
card.addEventListener('click', () => Library.select(asset.id));
return card;
}
function placeholder() {
const p = document.createElement('div');
p.className = 'asset-thumb-placeholder';
p.textContent = 'no preview';
return p;
}
Library.select = function (id) {
Library.state.selectedId = id;
Library.render();
};
Library.selectedAsset = function () {
return Library.state.assets.find(a => a.id === Library.state.selectedId) || null;
};
Library.syncActions = function () {
const sel = Library.selectedAsset();
const info = UI.$('#selected-info');
if (sel) {
info.textContent = (sel.display_name || sel.filename || sel.id)
+ (sel.file_size ? ' · ' + UI.formatBytes(Number(sel.file_size)) : '');
info.classList.remove('muted');
} else {
info.textContent = 'No asset selected';
info.classList.add('muted');
}
UI.$('#import-proxy-btn').disabled = !sel;
UI.$('#import-hires-btn').disabled = !sel || !sel.original_s3_key;
};
Library.refresh = async function (query) {
const grid = UI.$('#asset-grid');
grid.innerHTML = '<div class="empty muted">Loading…</div>';
try {
const data = await API.listAssets(query);
Library.state.assets = (data && (data.assets || data.rows)) || [];
Library.render();
} catch (e) {
grid.innerHTML = '';
const err = document.createElement('div');
err.className = 'empty muted';
err.textContent = 'Error loading assets: ' + e.message;
grid.appendChild(err);
}
};
window.Library = Library;
})();

View file

@ -0,0 +1,104 @@
// Panel bootstrap. Wires DOM events to API / Library / Import handlers and
// restores the connection from localStorage on mount.
(function () {
// Avoid running twice if UXP reloads the panel.
if (window.__df_uxp_started) return;
window.__df_uxp_started = true;
function syncConnectBtn() {
const u = UI.$('#server-url').value.trim();
const t = UI.$('#api-token').value.trim();
UI.$('#connect-btn').disabled = !u || !t;
}
async function tryConnect(serverUrl, apiToken) {
UI.setStatus('#connect-status', 'Connecting…', 'muted');
try {
const me = await API.connect(serverUrl, apiToken);
UI.$('#connected-host').textContent =
(me.user && (me.user.display_name || me.user.username)) ?
(me.user.display_name || me.user.username) + ' @ ' + serverUrl
: serverUrl;
UI.setStatus('#connect-status', '', 'muted');
UI.showPane('library');
await Library.refresh('');
} catch (e) {
API.state.connected = false;
UI.setStatus('#connect-status', 'Connect failed: ' + e.message, 'error');
UI.showPane('connect');
}
}
function wireConnectPane() {
UI.$('#server-url').value = API.state.serverUrl;
UI.$('#api-token').value = API.state.apiToken;
syncConnectBtn();
['input', 'change'].forEach(ev => {
UI.$('#server-url').addEventListener(ev, syncConnectBtn);
UI.$('#api-token').addEventListener(ev, syncConnectBtn);
});
UI.$('#connect-btn').addEventListener('click', async () => {
const u = UI.$('#server-url').value.trim();
const t = UI.$('#api-token').value.trim();
await tryConnect(u, t);
});
}
function wireLibraryPane() {
UI.$('#disconnect-btn').addEventListener('click', () => {
API.disconnect();
UI.$('#server-url').value = '';
UI.$('#api-token').value = '';
syncConnectBtn();
UI.showPane('connect');
UI.setStatus('#connect-status', 'Disconnected.', 'muted');
});
let searchTimer = null;
UI.$('#search-input').addEventListener('input', (e) => {
clearTimeout(searchTimer);
const q = e.target.value;
searchTimer = setTimeout(() => Library.refresh(q), 250);
});
UI.$('#refresh-btn').addEventListener('click', () => Library.refresh(UI.$('#search-input').value));
UI.$('#import-proxy-btn').addEventListener('click', async () => {
const a = Library.selectedAsset();
if (!a) return;
UI.$('#import-proxy-btn').disabled = true;
UI.$('#import-hires-btn').disabled = true;
try { await Import.proxy(a); }
catch (e) { UI.hideProgress(); UI.toast('Proxy import failed: ' + e.message, 'error'); }
finally { Library.syncActions(); }
});
UI.$('#import-hires-btn').addEventListener('click', async () => {
const a = Library.selectedAsset();
if (!a) return;
UI.$('#import-proxy-btn').disabled = true;
UI.$('#import-hires-btn').disabled = true;
try { await Import.hires(a); }
catch (e) { UI.hideProgress(); UI.toast('Hi-res import failed: ' + e.message, 'error'); }
finally { Library.syncActions(); }
});
}
function init() {
wireConnectPane();
wireLibraryPane();
// If we have stored creds, try to reconnect silently. On failure fall
// back to the connect pane so the user can retype.
if (API.state.serverUrl && API.state.apiToken) {
tryConnect(API.state.serverUrl, API.state.apiToken);
} else {
UI.showPane('connect');
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();

View file

@ -0,0 +1,61 @@
// Small DOM + state helpers used by the rest of the panel. UMD-style (attaches
// to window.UI) because UXP loads scripts as classic scripts, not modules.
(function () {
const UI = {};
UI.$ = function (sel) { return document.querySelector(sel); };
UI.setHidden = function (sel, hidden) {
const el = typeof sel === 'string' ? UI.$(sel) : sel;
if (!el) return;
el.classList.toggle('hidden', !!hidden);
};
UI.showPane = function (name) {
// name in {connect, library}
UI.setHidden('#connect-pane', name !== 'connect');
UI.setHidden('#library-pane', name !== 'library');
};
UI.setStatus = function (sel, text, kind) {
const el = typeof sel === 'string' ? UI.$(sel) : sel;
if (!el) return;
el.textContent = text || '';
el.classList.remove('error', 'muted');
if (kind === 'error') el.classList.add('error');
if (kind === 'muted') el.classList.add('muted');
};
UI.toast = function (msg, kind) {
const el = UI.$('#toast');
if (!el) return;
el.textContent = msg;
el.classList.remove('hidden', 'ok', 'error');
if (kind) el.classList.add(kind);
clearTimeout(UI._toastTimer);
UI._toastTimer = setTimeout(() => el.classList.add('hidden'), 6000);
};
UI.showProgress = function (label, pct) {
UI.setHidden('#progress-row', false);
UI.$('#progress-label').textContent = label;
UI.$('#progress-fill').style.width = Math.max(0, Math.min(100, pct || 0)) + '%';
};
UI.hideProgress = function () { UI.setHidden('#progress-row', true); };
UI.formatBytes = function (n) {
if (!Number.isFinite(n)) return '';
const u = ['B', 'KB', 'MB', 'GB', 'TB'];
let i = 0;
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
return n.toFixed(n < 10 && i > 0 ? 1 : 0) + ' ' + u[i];
};
UI.sanitizeFilename = function (s) {
return String(s || 'asset').replace(/[\\/:*?"<>|]/g, '_').slice(0, 120);
};
window.UI = UI;
})();

View file

@ -0,0 +1,211 @@
/* Dragonflight UXP panel — design tokens mirror the web UI's dark theme. */
:root {
--bg-0: #0e0f12;
--bg-1: #16181d;
--bg-2: #1c1f25;
--bg-3: #232730;
--border: #2a2f3a;
--text-1: #e6e8ec;
--text-2: #b6bac3;
--text-3: #7e848f;
--accent: #4f7cff;
--accent-hover: #6a91ff;
--danger: #e25c5c;
--danger-soft: #3a1d20;
--ok: #4ec07a;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
background: var(--bg-0);
color: var(--text-1);
font: 12px/1.4 system-ui, -apple-system, "Segoe UI", sans-serif;
height: 100%;
overflow: hidden;
}
#root { height: 100%; display: flex; flex-direction: column; }
.pane { display: flex; flex-direction: column; height: 100%; padding: 12px; }
.pane.hidden { display: none; }
/* ── connect screen ──────────────────────────────────────────────── */
.brand { text-align: center; margin: 12px 0 18px; }
.brand-title { font-size: 18px; font-weight: 600; color: var(--text-1); }
.brand-tag {
font-size: 9.5px;
font-weight: 600;
color: var(--text-3);
margin-top: 4px;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.label {
display: block;
font-size: 9.5px;
font-weight: 600;
color: var(--text-2);
letter-spacing: 0.06em;
text-transform: uppercase;
margin: 8px 0 4px;
}
input[type="text"],
input[type="password"],
input[type="search"] {
width: 100%;
background: var(--bg-3);
color: var(--text-1);
border: 1px solid var(--border);
border-radius: 4px;
padding: 6px 8px;
font-size: 12px;
outline: none;
}
input:focus { border-color: var(--accent); }
/* ── buttons ─────────────────────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 600;
padding: 7px 12px;
border: 1px solid transparent;
border-radius: 4px;
cursor: pointer;
background: var(--bg-3);
color: var(--text-1);
user-select: none;
}
.btn:disabled { opacity: 0.45; cursor: default; }
.btn-primary { background: var(--accent); color: #fff; }
.btn-primary:not(:disabled):hover { background: var(--accent-hover); }
.btn-secondary { background: var(--bg-3); color: var(--text-1); border-color: var(--border); }
.btn-link { background: transparent; color: var(--text-3); padding: 4px 6px; font-weight: 500; }
.btn-link:hover { color: var(--text-1); }
.btn-icon { padding: 4px 8px; background: var(--bg-3); border: 1px solid var(--border); }
.btn-icon:hover { background: var(--bg-2); }
#connect-btn { margin-top: 14px; width: 100%; padding: 8px; }
/* ── status / dot indicators ─────────────────────────────────────── */
.status { font-size: 11px; margin-top: 8px; min-height: 16px; }
.status.error { color: var(--danger); }
.status.muted { color: var(--text-3); }
.dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; margin-right: 6px; }
.dot-ok { background: var(--ok); }
.dot-bad { background: var(--danger); }
/* ── connected bar ───────────────────────────────────────────────── */
.connected-bar {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
background: var(--bg-1);
border: 1px solid var(--border);
border-radius: 4px;
margin-bottom: 10px;
}
.connected-host { font-family: ui-monospace, Menlo, monospace; font-size: 11px; color: var(--text-2); flex: 1; }
.library-controls { display: flex; gap: 6px; margin-bottom: 8px; }
.library-controls input { flex: 1; }
/* ── asset grid ──────────────────────────────────────────────────── */
.asset-grid {
flex: 1;
overflow-y: auto;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 6px;
padding: 2px;
align-content: start;
}
.asset-card {
background: var(--bg-2);
border: 1px solid var(--border);
border-radius: 4px;
overflow: hidden;
cursor: pointer;
display: flex;
flex-direction: column;
}
.asset-card:hover { border-color: var(--text-3); }
.asset-card.selected { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
.asset-thumb {
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
background: var(--bg-3);
display: block;
}
.asset-thumb-placeholder {
width: 100%;
aspect-ratio: 16 / 9;
background: var(--bg-3);
display: flex;
align-items: center;
justify-content: center;
color: var(--text-3);
font-size: 10px;
}
.asset-name {
font-size: 11px;
padding: 4px 6px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--text-1);
}
.empty { padding: 24px; text-align: center; }
.muted { color: var(--text-3); }
/* ── actions / progress / toast ──────────────────────────────────── */
.actions {
border-top: 1px solid var(--border);
padding-top: 8px;
margin-top: 8px;
}
.selected-info { font-size: 11px; margin-bottom: 6px; min-height: 16px; }
.action-row { display: flex; gap: 6px; }
.action-row .btn { flex: 1; }
.progress-row { margin-top: 8px; }
.progress-bar {
height: 6px;
background: var(--bg-3);
border-radius: 3px;
overflow: hidden;
}
#progress-fill {
height: 100%;
background: var(--accent);
width: 0%;
transition: width 120ms linear;
}
.progress-label { font-size: 10.5px; color: var(--text-3); margin-top: 4px; }
.toast {
margin-top: 8px;
padding: 6px 8px;
border-radius: 4px;
font-size: 11px;
background: var(--bg-2);
border: 1px solid var(--border);
color: var(--text-1);
}
.toast.ok { border-color: var(--ok); color: var(--text-1); }
.toast.error { border-color: var(--danger); color: var(--text-1); background: var(--danger-soft); }
.hidden { display: none !important; }