@hadoku/watchparty-ui 0.45.3 → 0.45.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chunks/{AdminPanel-BQAtJ9Th.js → AdminPanel-dRa78e11.js} +4 -4
- package/dist/chunks/{AdminPanel-BQAtJ9Th.js.map → AdminPanel-dRa78e11.js.map} +1 -1
- package/dist/chunks/{GameModule-CIJ-c1IW.js → GameModule-BHIAXm1o.js} +4 -4
- package/dist/chunks/{GameModule-CIJ-c1IW.js.map → GameModule-BHIAXm1o.js.map} +1 -1
- package/dist/chunks/{MapEditor-BI2ZVk5C.js → MapEditor-Bm2wjOn2.js} +2 -2
- package/dist/chunks/{MapEditor-BI2ZVk5C.js.map → MapEditor-Bm2wjOn2.js.map} +1 -1
- package/dist/chunks/{MediaDrawer-BgILxxh4.js → MediaDrawer-D9rfR7MB.js} +4 -4
- package/dist/chunks/{MediaDrawer-BgILxxh4.js.map → MediaDrawer-D9rfR7MB.js.map} +1 -1
- package/dist/chunks/{PlaylistPanel-2V6H_3Gw.js → PlaylistPanel-Bm6S61sf.js} +127 -123
- package/dist/chunks/PlaylistPanel-Bm6S61sf.js.map +1 -0
- package/dist/chunks/{Room-DleCsQL0.js → Room-DfshEfvV.js} +2028 -2022
- package/dist/chunks/{Room-DleCsQL0.js.map → Room-DfshEfvV.js.map} +1 -1
- package/dist/chunks/{index-HIbKUgoo.js → index-Cj4OLYI-.js} +3 -3
- package/dist/chunks/{index-HIbKUgoo.js.map → index-Cj4OLYI-.js.map} +1 -1
- package/dist/chunks/{submissionsApi-mPYMf-jY.js → submissionsApi-CZEsLvUr.js} +2 -2
- package/dist/chunks/{submissionsApi-mPYMf-jY.js.map → submissionsApi-CZEsLvUr.js.map} +1 -1
- package/dist/index.js +1 -1
- package/package.json +3 -3
- package/dist/chunks/PlaylistPanel-2V6H_3Gw.js.map +0 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { E as T, w as c, Y as d, g as A, d as S } from "./index-
|
|
1
|
+
import { E as T, w as c, Y as d, g as A, d as S } from "./index-Cj4OLYI-.js";
|
|
2
2
|
function j(e) {
|
|
3
3
|
return e.trim().toLowerCase().startsWith("magnet:") ? "magnet" : "url";
|
|
4
4
|
}
|
|
@@ -314,4 +314,4 @@ export {
|
|
|
314
314
|
N as s,
|
|
315
315
|
te as u
|
|
316
316
|
};
|
|
317
|
-
//# sourceMappingURL=submissionsApi-
|
|
317
|
+
//# sourceMappingURL=submissionsApi-CZEsLvUr.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"submissionsApi-mPYMf-jY.js","sources":["../../src/lib/submissionsApi.ts"],"sourcesContent":["/**\n * Client for user submissions — \"a friend asked for this media\".\n *\n * HTTP, not socket events, for the same reason the tierlist is: a submission\n * is not room state. It belongs to a PERSON, it outlives every socket that\n * person ever opens, and `GET /api/submissions/mine` must be readable from any\n * room (or none).\n *\n * ── The rules live here, not in the component ──────────────────────────────\n * Everything that can be decided without a network is a pure exported\n * function, because the failure modes are exactly the ones a component test\n * cannot reach: a quota refusal that arrives with a number, a transition\n * refusal that arrives with two states, and a host-allowlist refusal that is\n * indistinguishable from a typo unless the reason is carried through. The\n * component renders what these return.\n *\n * ── Status codes the server can answer with ────────────────────────────────\n * The route (`http/routes/submissions.ts`) is the citation for each:\n * 400 `submissions/invalid_source` — `checkSource` refused, with a `reason`\n * 400 `submissions/invalid_body` — the Zod parse failed\n * 403 `submissions/no_identity` — friend tier, but no registry userId\n * 403 (adminAuth) — below friend tier\n * 404 `submissions/not_found` — no such id\n * 409 `submissions/invalid_transition` — with `from` and `to`\n * 429 — over the per-user pending cap\n * 503 `submissions/unavailable` — no store (STATS_DB_DIR empty)\n * A 429 body is read LIBERALLY (`remainingBytes` / `remaining_bytes` /\n * `remaining`) rather than pinned to one spelling: the number is the whole\n * point of that response, and a spelling mismatch would silently degrade it to\n * a bare \"over quota\" with no way to tell that from a server that sent none.\n */\nimport { getRuntimeServerOrigin } from './config';\nimport { getUserType } from './auth';\nimport { debug, warn, errorFields } from './debug';\nimport type { TSubmission, TSubmissionKind } from '@watchparty/shared';\n\n/** Why a submissions call did not succeed. */\nexport type SubmissionFailure =\n | { code: 'invalid_source'; reason?: string }\n | { code: 'invalid_body' }\n | { code: 'quota'; remainingBytes?: number }\n | { code: 'no_identity' }\n | { code: 'forbidden' }\n | { code: 'conflict'; from?: string; to?: string }\n | { code: 'not_found' }\n | { code: 'unavailable' }\n | { code: 'network' }\n | { code: 'unknown'; status: number };\n\nexport type SubmissionResult<T> =\n | { ok: true; value: T }\n | { ok: false; failure: SubmissionFailure };\n\n/**\n * Which kind of source this text is, decided by its scheme alone.\n *\n * One field, two kinds: asking a user to pick \"url\" or \"magnet\" from a select\n * is asking them to restate what they already typed. `magnet:` is the only\n * scheme with a second meaning here, so everything else is offered as a URL\n * and the SERVER decides whether it is acceptable — `checkSource` is the\n * boundary, and a client-side copy of the allowlist would be a second\n * allowlist that drifts.\n */\nexport function guessSubmissionKind(raw: string): TSubmissionKind {\n return raw.trim().toLowerCase().startsWith('magnet:') ? 'magnet' : 'url';\n}\n\n/** First numeric field among several accepted spellings. */\nfunction readNumber(body: Record<string, unknown>, keys: string[]): number | undefined {\n for (const key of keys) {\n const v = body[key];\n if (typeof v === 'number' && Number.isFinite(v) && v >= 0) return v;\n }\n return undefined;\n}\n\nfunction readString(body: Record<string, unknown>, key: string): string | undefined {\n const v = body[key];\n return typeof v === 'string' && v ? v : undefined;\n}\n\n/**\n * Turn a response status plus its parsed body into a failure.\n *\n * Pure and total: the body arrives as `unknown` because it is JSON off the\n * wire, and a server that answers a 429 with an HTML error page must still\n * produce `{ code: 'quota' }` rather than throwing inside a click handler.\n */\nexport function classifySubmissionFailure(status: number, body: unknown): SubmissionFailure {\n const obj: Record<string, unknown> =\n body && typeof body === 'object' && !Array.isArray(body)\n ? (body as Record<string, unknown>)\n : {};\n const error = readString(obj, 'error');\n\n if (status === 429) {\n // The route sends what it MEASURED (`pendingBytes`, `capBytes`); the user\n // wants the difference. Derive it rather than asking the server to send a\n // third number that could disagree with the two it already sent — and\n // clamp, because a cap lowered under an existing backlog makes it\n // negative and \"−2 GB free\" is worse than no figure at all.\n const pending = readNumber(obj, ['pendingBytes', 'pending_bytes']);\n const cap = readNumber(obj, ['capBytes', 'cap_bytes']);\n const remaining = readNumber(obj, ['remainingBytes', 'remaining_bytes', 'remaining'])\n ?? (pending !== undefined && cap !== undefined ? Math.max(0, cap - pending) : undefined);\n return { code: 'quota', remainingBytes: remaining };\n }\n if (status === 409) {\n return { code: 'conflict', from: readString(obj, 'from'), to: readString(obj, 'to') };\n }\n if (status === 404) return { code: 'not_found' };\n if (status === 503) return { code: 'unavailable' };\n if (status === 400) {\n if (error === 'submissions/invalid_source') {\n return { code: 'invalid_source', reason: readString(obj, 'reason') };\n }\n return { code: 'invalid_body' };\n }\n if (status === 401 || status === 403) {\n // `no_identity` is a friend who IS admitted and has nowhere to store a\n // row; a plain 403 is a caller below friend tier. They need different\n // words, and collapsing them makes a registry gap look like a permission.\n return error === 'submissions/no_identity' ? { code: 'no_identity' } : { code: 'forbidden' };\n }\n return { code: 'unknown', status };\n}\n\nconst UNITS = ['B', 'KB', 'MB', 'GB', 'TB'] as const;\n\n/** Bytes as a short human figure. Used only for the quota number. */\nexport function formatSubmissionBytes(bytes: number): string {\n if (!Number.isFinite(bytes) || bytes < 0) return '—';\n let n = bytes;\n let u = 0;\n while (n >= 1024 && u < UNITS.length - 1) { n /= 1024; u++; }\n const rounded = u === 0 ? String(Math.round(n)) : n.toFixed(n < 10 ? 1 : 0);\n return `${rounded} ${UNITS[u]}`;\n}\n\n/**\n * The failure as a short STATE line.\n *\n * Deliberately not a sentence and never an instruction: the UI rule for this\n * repo is to ship state rather than narration, so this is the same register as\n * the state badge beside it. The quota case is the one that carries a number,\n * because \"over quota\" with no figure gives the user nothing to act on.\n */\nexport function submissionFailureLabel(failure: SubmissionFailure): string {\n switch (failure.code) {\n case 'invalid_source':\n switch (failure.reason) {\n case 'host_not_allowed': return 'host not allowed';\n case 'not_https': return 'https only';\n case 'bad_magnet': return 'not a magnet';\n case 'bad_upload_ref': return 'bad upload ref';\n case 'unparseable': return 'not a link';\n default: return 'rejected';\n }\n case 'invalid_body': return 'rejected';\n case 'quota':\n return failure.remainingBytes === undefined\n ? 'over quota'\n : `over quota — ${formatSubmissionBytes(failure.remainingBytes)} free`;\n case 'no_identity': return 'no identity';\n case 'forbidden': return 'not allowed';\n case 'conflict': return failure.from ? `already ${failure.from}` : 'conflict';\n case 'not_found': return 'not found';\n case 'unavailable': return 'unavailable';\n case 'network': return 'network error';\n case 'unknown': return `error ${failure.status}`;\n }\n}\n\n/**\n * May this browser offer the submit surface at all?\n *\n * The API is friend tier and above. This is a membership test rather than a\n * rank comparison against a second copy of `TIER_RANK` — `auth.ts` owns that\n * ladder and does not export it, and a hand-copied rank table is exactly the\n * drift `isPrivileged`'s own comment records (the UI's copy was missed when\n * the server's exact-match allowlist was fixed). An unlisted tier is refused,\n * so the gate fails CLOSED; the server enforces the real boundary either way.\n */\nconst SUBMIT_TIERS = new Set(['friend', 'service', 'wife', 'admin']);\nexport function canSubmitMedia(): boolean {\n return SUBMIT_TIERS.has(getUserType());\n}\n\nfunction url(path: string): string {\n return `${getRuntimeServerOrigin()}${path}`;\n}\n\n/** Parse a body without letting a non-JSON error page throw. */\nasync function safeJson(res: Response): Promise<unknown> {\n try { return await res.json(); } catch { return null; }\n}\n\n/**\n * One request to `POST /api/submissions`, with whatever fields the caller\n * has. `series` is sent even though `SubmissionCreateIn` (shared) may not yet\n * declare it — a plain `z.object()` strips unknown keys rather than erroring,\n * so this is forward-compatible with the schema change landing independently:\n * nothing here needs to change again once it does.\n */\ninterface PostSubmissionInput {\n kind: TSubmissionKind;\n source: string;\n title?: string;\n /** Magnet only. Dropped silently by the server until it recognises the field. */\n series?: string;\n}\n\nasync function postSubmission(input: PostSubmissionInput): Promise<SubmissionResult<TSubmission>> {\n const body: Record<string, string> = { kind: input.kind, source: input.source };\n if (input.title) body.title = input.title;\n if (input.series) body.series = input.series;\n try {\n const res = await fetch(url('/api/submissions'), {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(body),\n });\n if (!res.ok) {\n const failure = classifySubmissionFailure(res.status, await safeJson(res));\n debug('submissions', 'Submit refused', { status: res.status, code: failure.code });\n return { ok: false, failure };\n }\n const parsed = (await res.json()) as { submission: TSubmission };\n debug('submissions', 'Submitted', { id: parsed.submission.id, kind: input.kind });\n return { ok: true, value: parsed.submission };\n } catch (err: unknown) {\n warn('submissions', 'Submit threw', errorFields(err));\n return { ok: false, failure: { code: 'network' } };\n }\n}\n\n/** Record a request. Always lands in `pending` — approval is the operator's. */\nexport async function submitSubmission(\n rawSource: string, title?: string,\n): Promise<SubmissionResult<TSubmission>> {\n const source = rawSource.trim();\n const kind = guessSubmissionKind(source);\n return postSubmission({ kind, source, title });\n}\n\n/**\n * A magnet carries a `series` — the anime folder the scraper will file it\n * under. `checkSource('magnet', …)` is the AUTHORITY on requiring it and on\n * the title's `SxxExx` shape; this call sends what the caller typed and lets\n * the server's 400 (`invalid_source`, reason `bad_magnet` or similar) speak\n * for a missing or malformed one rather than a client-side copy of that rule.\n */\nexport async function submitMagnetSubmission(\n rawSource: string, opts: { title?: string; series: string },\n): Promise<SubmissionResult<TSubmission>> {\n return postSubmission({\n kind: 'magnet', source: rawSource.trim(), title: opts.title, series: opts.series.trim(),\n });\n}\n\n/** Record an upload once its bytes are already sitting in the filetransfer\n * inbox. `fileId` is the filetransfer file id (`FileInfo.Id`) a chunk-upload\n * completed with — see {@link uploadFileForSubmission}. */\nexport async function submitUploadSubmission(\n fileId: string, title?: string,\n): Promise<SubmissionResult<TSubmission>> {\n return postSubmission({ kind: 'upload', source: fileId, title });\n}\n\n/**\n * Whether a title carries an episode code the scraper's destination rule\n * needs (`SxxExx`, any case). Pure and client-side as a courtesy ONLY — the\n * server's `checkSource('magnet', …)` is the one place this is enforced; see\n * mutation check (a) in the wave brief: dropping the requirement here must\n * still leave a bad title refused by the server.\n */\nconst SXXEXX_RE = /S\\d{2}E\\d{2}/i;\nexport function hasEpisodeCode(title: string): boolean {\n return SXXEXX_RE.test(title);\n}\n\n/**\n * A magnet's `dn=` (display name), if it has one — the title default per the\n * tracker's design call. `new URLSearchParams` is the same parse\n * `checkSource`'s `checkMagnet` uses server-side, so the two cannot disagree\n * about what a magnet's query string means.\n */\nexport function extractMagnetDisplayName(rawSource: string): string | undefined {\n const trimmed = rawSource.trim();\n if (!trimmed.toLowerCase().startsWith('magnet:?')) return undefined;\n try {\n const params = new URLSearchParams(trimmed.slice('magnet:?'.length));\n const dn = params.get('dn')?.trim();\n return dn || undefined;\n } catch {\n return undefined;\n }\n}\n\n/** The caller's own submissions, newest first. Never anybody else's. */\nexport async function fetchMySubmissions(): Promise<SubmissionResult<TSubmission[]>> {\n try {\n const res = await fetch(url('/api/submissions/mine'), { cache: 'no-store' });\n if (!res.ok) {\n return { ok: false, failure: classifySubmissionFailure(res.status, await safeJson(res)) };\n }\n const body = (await res.json()) as { submissions?: TSubmission[] };\n return { ok: true, value: Array.isArray(body.submissions) ? body.submissions : [] };\n } catch (err: unknown) {\n warn('submissions', 'List threw', errorFields(err));\n return { ok: false, failure: { code: 'network' } };\n }\n}\n\n/**\n * A failed moderation call as a short STATE line for the operator.\n *\n * A 409 here is not an error in the operator's own action, it is the normal\n * way to meet a stale queue page: another operator (or another tab) moved the\n * row first. Saying \"already approved\" would be wrong half the time — it may\n * have been rejected — and naming neither state hides that the page is behind,\n * so the line says who got there first and the row is dropped from the list.\n * The 404 is the same situation one step further on (the row is gone).\n */\nexport function decisionFailureLabel(failure: SubmissionFailure): string {\n switch (failure.code) {\n case 'conflict': return 'someone else already decided this';\n case 'not_found': return 'no longer exists';\n default: return submissionFailureLabel(failure);\n }\n}\n\n/** Whether a failed decision means the row is no longer `pending` server-side. */\nexport function decisionMeansRowMoved(failure: SubmissionFailure): boolean {\n return failure.code === 'conflict' || failure.code === 'not_found';\n}\n\n/** Age as one coarse unit: `45s`, `12m`, `3h`, `2d`. Never negative. */\nexport function formatSubmissionAge(createdAt: number, now: number): string {\n const s = Math.max(0, Math.floor((now - createdAt) / 1000));\n if (s < 60) return `${s}s`;\n if (s < 3600) return `${Math.floor(s / 60)}m`;\n if (s < 86400) return `${Math.floor(s / 3600)}h`;\n return `${Math.floor(s / 86400)}d`;\n}\n\n/** The operator queue: every `pending` row, oldest first (service tier). */\nexport async function fetchPendingSubmissions(): Promise<SubmissionResult<TSubmission[]>> {\n try {\n const res = await fetch(url('/api/submissions?state=pending'), { cache: 'no-store' });\n if (!res.ok) {\n return { ok: false, failure: classifySubmissionFailure(res.status, await safeJson(res)) };\n }\n const body = (await res.json()) as { submissions?: TSubmission[] };\n const rows = Array.isArray(body.submissions) ? body.submissions : [];\n return { ok: true, value: [...rows].sort((a, b) => a.createdAt - b.createdAt) };\n } catch (err: unknown) {\n warn('submissions', 'Queue list threw', errorFields(err));\n return { ok: false, failure: { code: 'network' } };\n }\n}\n\nasync function decide(\n id: string, verb: 'approve' | 'reject', body?: { reason: string },\n): Promise<SubmissionResult<TSubmission>> {\n try {\n const res = await fetch(url(`/api/submissions/${encodeURIComponent(id)}/${verb}`), {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(body ?? {}),\n });\n if (!res.ok) {\n const failure = classifySubmissionFailure(res.status, await safeJson(res));\n debug('submissions', 'Decision refused', { verb, status: res.status, code: failure.code });\n return { ok: false, failure };\n }\n const parsed = (await res.json()) as { submission: TSubmission };\n return { ok: true, value: parsed.submission };\n } catch (err: unknown) {\n warn('submissions', 'Decision threw', errorFields(err));\n return { ok: false, failure: { code: 'network' } };\n }\n}\n\nexport function approveSubmission(id: string): Promise<SubmissionResult<TSubmission>> {\n return decide(id, 'approve');\n}\n\n/** A blank reason is omitted: the server's schema refuses an empty string. */\nexport function rejectSubmission(id: string, reason: string): Promise<SubmissionResult<TSubmission>> {\n const trimmed = reason.trim();\n return decide(id, 'reject', trimmed ? { reason: trimmed } : undefined);\n}\n\n// ── Uploads: filetransfer chunk upload ──────────────────────────────────────\n//\n// The server never hands the browser its own SERVICE_KEY — `upload-target`\n// resolves a recipient userId and a cap server-side (see\n// `http/routes/submissions.ts`). What the browser does with them is Gokapi's\n// OWN chunked-upload protocol, verified against filetransfer's own browser\n// client (`hadoku-filetransfer/packages/ui/src/lib/upload.ts`) rather than\n// guessed: `POST /uploadChunk` per slice (session-authed via the\n// `hadoku_session` cookie — same-origin with this app's own apex, so\n// `credentials: 'include'` is what carries it, same as that client), then\n// `GET /auth/token` for a short-lived apikey, then `POST /api/chunk/complete`\n// with the assembly parameters as HEADERS (not form fields — Gokapi's API\n// routes read `r.Header.Get`) plus `X-Hadoku-Recipient`, which is what makes\n// this a DIRECT transfer rather than an ordinary link-shared file.\n\n/** What the server resolved for this upload. */\nexport interface UploadTarget { recipient: string; maxBytes: number; uploadUrl: string }\n\n/** `GET /api/submissions/upload-target` — friend tier. */\nexport async function fetchUploadTarget(): Promise<SubmissionResult<UploadTarget>> {\n try {\n const res = await fetch(url('/api/submissions/upload-target'), { cache: 'no-store' });\n if (!res.ok) {\n return { ok: false, failure: classifySubmissionFailure(res.status, await safeJson(res)) };\n }\n const body = (await res.json()) as Partial<UploadTarget>;\n if (\n typeof body.recipient !== 'string' || !body.recipient ||\n typeof body.maxBytes !== 'number' || typeof body.uploadUrl !== 'string' || !body.uploadUrl\n ) {\n return { ok: false, failure: { code: 'unknown', status: res.status } };\n }\n return { ok: true, value: { recipient: body.recipient, maxBytes: body.maxBytes, uploadUrl: body.uploadUrl } };\n } catch (err: unknown) {\n warn('submissions', 'Upload target fetch threw', errorFields(err));\n return { ok: false, failure: { code: 'network' } };\n }\n}\n\n/** Why a chunk upload did not produce a filetransfer file id. */\nexport type UploadFailure =\n | { code: 'too_large'; maxBytes: number }\n | { code: 'target_unavailable' }\n | { code: 'upload_failed'; message: string };\n\nexport type UploadResult<T> = { ok: true; value: T } | { ok: false; failure: UploadFailure };\n\nexport function uploadFailureLabel(failure: UploadFailure): string {\n switch (failure.code) {\n case 'too_large': return `over ${formatSubmissionBytes(failure.maxBytes)} cap`;\n case 'target_unavailable': return 'upload unavailable';\n case 'upload_failed': return 'upload failed';\n }\n}\n\nexport type UploadPhase = 'uploading' | 'assembling';\n\n/** Per-request slice size: above Gokapi's 5 MB floor for a non-final chunk,\n * below Cloudflare's 100 MB single-request body ceiling. Matches\n * filetransfer's own client, which measured both bounds against the live\n * backend. */\nconst UPLOAD_CHUNK_BYTES = 40 * 1024 * 1024;\nconst UPLOAD_REQUEST_TIMEOUT_MS = 10 * 60 * 1000;\nconst UPLOAD_ATTEMPTS = 5;\n/** This is a machine-to-machine staging area, not a shared download link —\n * generous enough that neither bound is what makes the transfer fail before\n * the runner (wave 4, lane A) collects it. */\nconst UPLOAD_ALLOWED_DOWNLOADS = 50;\nconst UPLOAD_EXPIRY_DAYS = 14;\n\n/** A response retrying cannot fix — a 4xx means the request itself is wrong. */\nclass FatalUploadError extends Error {}\n\nasync function uploadFetchWithRetry(input: string, init: RequestInit, what: string): Promise<Response> {\n let last: unknown;\n for (let attempt = 0; attempt < UPLOAD_ATTEMPTS; attempt++) {\n if (attempt > 0) {\n await new Promise(r => setTimeout(r, Math.min(1000 * 2 ** (attempt - 1), 8000)));\n }\n try {\n const res = await fetch(input, { ...init, signal: AbortSignal.timeout(UPLOAD_REQUEST_TIMEOUT_MS) });\n if (res.ok) return res;\n if (res.status < 500 && res.status !== 429) {\n throw new FatalUploadError(`${what} failed (${res.status})`);\n }\n last = new Error(`${what} failed (${res.status})`);\n } catch (err: unknown) {\n if (err instanceof FatalUploadError) throw err;\n last = err;\n }\n }\n const detail = last instanceof Error ? last.message : String(last);\n throw new Error(`${what} failed after ${UPLOAD_ATTEMPTS} attempts — ${detail}`);\n}\n\n/** UTF-8-aware base64, matching what Gokapi's `base64:` header prefix decodes\n * (`btoa` alone throws on any character outside Latin-1, which a unicode\n * filename reliably has). */\nfunction b64(s: string): string {\n return btoa(String.fromCharCode(...new TextEncoder().encode(s)));\n}\n\nasync function mintUploadToken(uploadUrl: string): Promise<string> {\n const res = await fetch(`${uploadUrl}/auth/token`, {\n credentials: 'include',\n headers: { permission: 'PERM_UPLOAD' },\n });\n if (!res.ok) throw new FatalUploadError(`could not obtain an upload token (${res.status})`);\n const body = (await res.json()) as { key?: string };\n if (!body.key) throw new Error('token response had no key');\n return body.key;\n}\n\n/**\n * Chunk-upload `file` to filetransfer, addressed to `target.recipient`, and\n * return the filetransfer file id a submission's `source` should carry.\n *\n * The 4 GB cap is checked here BEFORE any network call — a courtesy only; the\n * server enforces it authoritatively on bytes actually received (wave 4,\n * lane A), because the size reported here is advisory.\n */\nexport async function uploadFileForSubmission(\n file: File,\n target: UploadTarget,\n onProgress?: (sent: number, total: number, phase: UploadPhase) => void,\n): Promise<UploadResult<{ fileId: string; bytes: number }>> {\n if (file.size > target.maxBytes) {\n return { ok: false, failure: { code: 'too_large', maxBytes: target.maxBytes } };\n }\n try {\n const id = crypto.randomUUID();\n const total = file.size;\n onProgress?.(0, total, 'uploading');\n\n for (let offset = 0; offset < total || (total === 0 && offset === 0); offset += UPLOAD_CHUNK_BYTES) {\n const slice = file.slice(offset, Math.min(offset + UPLOAD_CHUNK_BYTES, total));\n const form = new FormData();\n // Dropzone's own field names — Gokapi's non-API chunk endpoint parses\n // these with `isApiCall=false`, not the header form step 3 uses.\n form.append('dzuuid', id);\n form.append('dzchunkbyteoffset', String(offset));\n form.append('dztotalfilesize', String(total));\n form.append('file', slice, file.name);\n await uploadFetchWithRetry(\n `${target.uploadUrl}/uploadChunk`,\n { method: 'POST', credentials: 'include', body: form },\n `sending the part at ${Math.round(offset / 1024 / 1024)} MB`,\n );\n onProgress?.(Math.min(offset + UPLOAD_CHUNK_BYTES, total), total, 'uploading');\n if (total === 0) break;\n }\n\n onProgress?.(total, total, 'assembling');\n\n // Fresh key per attempt, same as filetransfer's own client: a key that\n // aged out mid-retry should not be reused by the next attempt.\n let res: Response | null = null;\n let lastErr: unknown;\n for (let attempt = 0; attempt < 2 && !res; attempt++) {\n const key = await mintUploadToken(target.uploadUrl);\n const headers: Record<string, string> = {\n uuid: id,\n filename: `base64:${b64(file.name)}`,\n filesize: String(total),\n contenttype: file.type || 'application/octet-stream',\n allowedDownloads: String(UPLOAD_ALLOWED_DOWNLOADS),\n expiryDays: String(UPLOAD_EXPIRY_DAYS),\n apikey: key,\n // The direct-transfer binding. The shim intercepts exactly this call\n // when this header is present and records (owner, recipient) against\n // the file id in its own response body.\n 'X-Hadoku-Recipient': target.recipient,\n };\n try {\n res = await uploadFetchWithRetry(\n `${target.uploadUrl}/api/chunk/complete`,\n { method: 'POST', credentials: 'include', headers },\n 'assembling the upload',\n );\n } catch (err: unknown) {\n lastErr = err;\n if (!(err instanceof FatalUploadError && /\\((401|403)\\)/.test(err.message))) throw err;\n }\n }\n if (!res) throw lastErr instanceof Error ? lastErr : new Error(String(lastErr));\n\n const body = (await res.json()) as { FileInfo?: { Id?: string; SizeBytes?: number } };\n if (!body.FileInfo?.Id) {\n return { ok: false, failure: { code: 'upload_failed', message: 'no file info returned' } };\n }\n const bytes = typeof body.FileInfo.SizeBytes === 'number' ? body.FileInfo.SizeBytes : total;\n return { ok: true, value: { fileId: body.FileInfo.Id, bytes } };\n } catch (err: unknown) {\n warn('submissions', 'Upload threw', errorFields(err));\n const message = err instanceof Error ? err.message : String(err);\n return { ok: false, failure: { code: 'upload_failed', message } };\n }\n}\n"],"names":["guessSubmissionKind","raw","readNumber","body","keys","key","v","readString","classifySubmissionFailure","status","obj","error","pending","cap","UNITS","formatSubmissionBytes","bytes","n","u","submissionFailureLabel","failure","SUBMIT_TIERS","canSubmitMedia","getUserType","url","path","getRuntimeServerOrigin","safeJson","res","postSubmission","input","debug","parsed","err","warn","errorFields","submitSubmission","rawSource","title","source","kind","submitMagnetSubmission","opts","submitUploadSubmission","fileId","SXXEXX_RE","hasEpisodeCode","extractMagnetDisplayName","trimmed","fetchMySubmissions","decisionFailureLabel","decisionMeansRowMoved","formatSubmissionAge","createdAt","now","s","fetchPendingSubmissions","a","b","decide","id","verb","approveSubmission","rejectSubmission","reason","fetchUploadTarget","uploadFailureLabel","UPLOAD_CHUNK_BYTES","UPLOAD_REQUEST_TIMEOUT_MS","UPLOAD_ATTEMPTS","UPLOAD_ALLOWED_DOWNLOADS","UPLOAD_EXPIRY_DAYS","FatalUploadError","uploadFetchWithRetry","init","what","last","attempt","r","detail","b64","mintUploadToken","uploadUrl","uploadFileForSubmission","file","target","onProgress","total","offset","slice","form","lastErr","headers"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"submissionsApi-CZEsLvUr.js","sources":["../../src/lib/submissionsApi.ts"],"sourcesContent":["/**\n * Client for user submissions — \"a friend asked for this media\".\n *\n * HTTP, not socket events, for the same reason the tierlist is: a submission\n * is not room state. It belongs to a PERSON, it outlives every socket that\n * person ever opens, and `GET /api/submissions/mine` must be readable from any\n * room (or none).\n *\n * ── The rules live here, not in the component ──────────────────────────────\n * Everything that can be decided without a network is a pure exported\n * function, because the failure modes are exactly the ones a component test\n * cannot reach: a quota refusal that arrives with a number, a transition\n * refusal that arrives with two states, and a host-allowlist refusal that is\n * indistinguishable from a typo unless the reason is carried through. The\n * component renders what these return.\n *\n * ── Status codes the server can answer with ────────────────────────────────\n * The route (`http/routes/submissions.ts`) is the citation for each:\n * 400 `submissions/invalid_source` — `checkSource` refused, with a `reason`\n * 400 `submissions/invalid_body` — the Zod parse failed\n * 403 `submissions/no_identity` — friend tier, but no registry userId\n * 403 (adminAuth) — below friend tier\n * 404 `submissions/not_found` — no such id\n * 409 `submissions/invalid_transition` — with `from` and `to`\n * 429 — over the per-user pending cap\n * 503 `submissions/unavailable` — no store (STATS_DB_DIR empty)\n * A 429 body is read LIBERALLY (`remainingBytes` / `remaining_bytes` /\n * `remaining`) rather than pinned to one spelling: the number is the whole\n * point of that response, and a spelling mismatch would silently degrade it to\n * a bare \"over quota\" with no way to tell that from a server that sent none.\n */\nimport { getRuntimeServerOrigin } from './config';\nimport { getUserType } from './auth';\nimport { debug, warn, errorFields } from './debug';\nimport type { TSubmission, TSubmissionKind } from '@watchparty/shared';\n\n/** Why a submissions call did not succeed. */\nexport type SubmissionFailure =\n | { code: 'invalid_source'; reason?: string }\n | { code: 'invalid_body' }\n | { code: 'quota'; remainingBytes?: number }\n | { code: 'no_identity' }\n | { code: 'forbidden' }\n | { code: 'conflict'; from?: string; to?: string }\n | { code: 'not_found' }\n | { code: 'unavailable' }\n | { code: 'network' }\n | { code: 'unknown'; status: number };\n\nexport type SubmissionResult<T> =\n | { ok: true; value: T }\n | { ok: false; failure: SubmissionFailure };\n\n/**\n * Which kind of source this text is, decided by its scheme alone.\n *\n * One field, two kinds: asking a user to pick \"url\" or \"magnet\" from a select\n * is asking them to restate what they already typed. `magnet:` is the only\n * scheme with a second meaning here, so everything else is offered as a URL\n * and the SERVER decides whether it is acceptable — `checkSource` is the\n * boundary, and a client-side copy of the allowlist would be a second\n * allowlist that drifts.\n */\nexport function guessSubmissionKind(raw: string): TSubmissionKind {\n return raw.trim().toLowerCase().startsWith('magnet:') ? 'magnet' : 'url';\n}\n\n/** First numeric field among several accepted spellings. */\nfunction readNumber(body: Record<string, unknown>, keys: string[]): number | undefined {\n for (const key of keys) {\n const v = body[key];\n if (typeof v === 'number' && Number.isFinite(v) && v >= 0) return v;\n }\n return undefined;\n}\n\nfunction readString(body: Record<string, unknown>, key: string): string | undefined {\n const v = body[key];\n return typeof v === 'string' && v ? v : undefined;\n}\n\n/**\n * Turn a response status plus its parsed body into a failure.\n *\n * Pure and total: the body arrives as `unknown` because it is JSON off the\n * wire, and a server that answers a 429 with an HTML error page must still\n * produce `{ code: 'quota' }` rather than throwing inside a click handler.\n */\nexport function classifySubmissionFailure(status: number, body: unknown): SubmissionFailure {\n const obj: Record<string, unknown> =\n body && typeof body === 'object' && !Array.isArray(body)\n ? (body as Record<string, unknown>)\n : {};\n const error = readString(obj, 'error');\n\n if (status === 429) {\n // The route sends what it MEASURED (`pendingBytes`, `capBytes`); the user\n // wants the difference. Derive it rather than asking the server to send a\n // third number that could disagree with the two it already sent — and\n // clamp, because a cap lowered under an existing backlog makes it\n // negative and \"−2 GB free\" is worse than no figure at all.\n const pending = readNumber(obj, ['pendingBytes', 'pending_bytes']);\n const cap = readNumber(obj, ['capBytes', 'cap_bytes']);\n const remaining = readNumber(obj, ['remainingBytes', 'remaining_bytes', 'remaining'])\n ?? (pending !== undefined && cap !== undefined ? Math.max(0, cap - pending) : undefined);\n return { code: 'quota', remainingBytes: remaining };\n }\n if (status === 409) {\n return { code: 'conflict', from: readString(obj, 'from'), to: readString(obj, 'to') };\n }\n if (status === 404) return { code: 'not_found' };\n if (status === 503) return { code: 'unavailable' };\n if (status === 400) {\n if (error === 'submissions/invalid_source') {\n return { code: 'invalid_source', reason: readString(obj, 'reason') };\n }\n return { code: 'invalid_body' };\n }\n if (status === 401 || status === 403) {\n // `no_identity` is a friend who IS admitted and has nowhere to store a\n // row; a plain 403 is a caller below friend tier. They need different\n // words, and collapsing them makes a registry gap look like a permission.\n return error === 'submissions/no_identity' ? { code: 'no_identity' } : { code: 'forbidden' };\n }\n return { code: 'unknown', status };\n}\n\nconst UNITS = ['B', 'KB', 'MB', 'GB', 'TB'] as const;\n\n/** Bytes as a short human figure. Used only for the quota number. */\nexport function formatSubmissionBytes(bytes: number): string {\n if (!Number.isFinite(bytes) || bytes < 0) return '—';\n let n = bytes;\n let u = 0;\n while (n >= 1024 && u < UNITS.length - 1) { n /= 1024; u++; }\n const rounded = u === 0 ? String(Math.round(n)) : n.toFixed(n < 10 ? 1 : 0);\n return `${rounded} ${UNITS[u]}`;\n}\n\n/**\n * The failure as a short STATE line.\n *\n * Deliberately not a sentence and never an instruction: the UI rule for this\n * repo is to ship state rather than narration, so this is the same register as\n * the state badge beside it. The quota case is the one that carries a number,\n * because \"over quota\" with no figure gives the user nothing to act on.\n */\nexport function submissionFailureLabel(failure: SubmissionFailure): string {\n switch (failure.code) {\n case 'invalid_source':\n switch (failure.reason) {\n case 'host_not_allowed': return 'host not allowed';\n case 'not_https': return 'https only';\n case 'bad_magnet': return 'not a magnet';\n case 'bad_upload_ref': return 'bad upload ref';\n case 'unparseable': return 'not a link';\n default: return 'rejected';\n }\n case 'invalid_body': return 'rejected';\n case 'quota':\n return failure.remainingBytes === undefined\n ? 'over quota'\n : `over quota — ${formatSubmissionBytes(failure.remainingBytes)} free`;\n case 'no_identity': return 'no identity';\n case 'forbidden': return 'not allowed';\n case 'conflict': return failure.from ? `already ${failure.from}` : 'conflict';\n case 'not_found': return 'not found';\n case 'unavailable': return 'unavailable';\n case 'network': return 'network error';\n case 'unknown': return `error ${failure.status}`;\n }\n}\n\n/**\n * May this browser offer the submit surface at all?\n *\n * The API is friend tier and above. This is a membership test rather than a\n * rank comparison against a second copy of `TIER_RANK` — `auth.ts` owns that\n * ladder and does not export it, and a hand-copied rank table is exactly the\n * drift `isPrivileged`'s own comment records (the UI's copy was missed when\n * the server's exact-match allowlist was fixed). An unlisted tier is refused,\n * so the gate fails CLOSED; the server enforces the real boundary either way.\n */\nconst SUBMIT_TIERS = new Set(['friend', 'service', 'wife', 'admin']);\nexport function canSubmitMedia(): boolean {\n return SUBMIT_TIERS.has(getUserType());\n}\n\nfunction url(path: string): string {\n return `${getRuntimeServerOrigin()}${path}`;\n}\n\n/** Parse a body without letting a non-JSON error page throw. */\nasync function safeJson(res: Response): Promise<unknown> {\n try { return await res.json(); } catch { return null; }\n}\n\n/**\n * One request to `POST /api/submissions`, with whatever fields the caller\n * has. `series` is sent even though `SubmissionCreateIn` (shared) may not yet\n * declare it — a plain `z.object()` strips unknown keys rather than erroring,\n * so this is forward-compatible with the schema change landing independently:\n * nothing here needs to change again once it does.\n */\ninterface PostSubmissionInput {\n kind: TSubmissionKind;\n source: string;\n title?: string;\n /** Magnet only. Dropped silently by the server until it recognises the field. */\n series?: string;\n}\n\nasync function postSubmission(input: PostSubmissionInput): Promise<SubmissionResult<TSubmission>> {\n const body: Record<string, string> = { kind: input.kind, source: input.source };\n if (input.title) body.title = input.title;\n if (input.series) body.series = input.series;\n try {\n const res = await fetch(url('/api/submissions'), {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(body),\n });\n if (!res.ok) {\n const failure = classifySubmissionFailure(res.status, await safeJson(res));\n debug('submissions', 'Submit refused', { status: res.status, code: failure.code });\n return { ok: false, failure };\n }\n const parsed = (await res.json()) as { submission: TSubmission };\n debug('submissions', 'Submitted', { id: parsed.submission.id, kind: input.kind });\n return { ok: true, value: parsed.submission };\n } catch (err: unknown) {\n warn('submissions', 'Submit threw', errorFields(err));\n return { ok: false, failure: { code: 'network' } };\n }\n}\n\n/** Record a request. Always lands in `pending` — approval is the operator's. */\nexport async function submitSubmission(\n rawSource: string, title?: string,\n): Promise<SubmissionResult<TSubmission>> {\n const source = rawSource.trim();\n const kind = guessSubmissionKind(source);\n return postSubmission({ kind, source, title });\n}\n\n/**\n * A magnet carries a `series` — the anime folder the scraper will file it\n * under. `checkSource('magnet', …)` is the AUTHORITY on requiring it and on\n * the title's `SxxExx` shape; this call sends what the caller typed and lets\n * the server's 400 (`invalid_source`, reason `bad_magnet` or similar) speak\n * for a missing or malformed one rather than a client-side copy of that rule.\n */\nexport async function submitMagnetSubmission(\n rawSource: string, opts: { title?: string; series: string },\n): Promise<SubmissionResult<TSubmission>> {\n return postSubmission({\n kind: 'magnet', source: rawSource.trim(), title: opts.title, series: opts.series.trim(),\n });\n}\n\n/** Record an upload once its bytes are already sitting in the filetransfer\n * inbox. `fileId` is the filetransfer file id (`FileInfo.Id`) a chunk-upload\n * completed with — see {@link uploadFileForSubmission}. */\nexport async function submitUploadSubmission(\n fileId: string, title?: string,\n): Promise<SubmissionResult<TSubmission>> {\n return postSubmission({ kind: 'upload', source: fileId, title });\n}\n\n/**\n * Whether a title carries an episode code the scraper's destination rule\n * needs (`SxxExx`, any case). Pure and client-side as a courtesy ONLY — the\n * server's `checkSource('magnet', …)` is the one place this is enforced; see\n * mutation check (a) in the wave brief: dropping the requirement here must\n * still leave a bad title refused by the server.\n */\nconst SXXEXX_RE = /S\\d{2}E\\d{2}/i;\nexport function hasEpisodeCode(title: string): boolean {\n return SXXEXX_RE.test(title);\n}\n\n/**\n * A magnet's `dn=` (display name), if it has one — the title default per the\n * tracker's design call. `new URLSearchParams` is the same parse\n * `checkSource`'s `checkMagnet` uses server-side, so the two cannot disagree\n * about what a magnet's query string means.\n */\nexport function extractMagnetDisplayName(rawSource: string): string | undefined {\n const trimmed = rawSource.trim();\n if (!trimmed.toLowerCase().startsWith('magnet:?')) return undefined;\n try {\n const params = new URLSearchParams(trimmed.slice('magnet:?'.length));\n const dn = params.get('dn')?.trim();\n return dn || undefined;\n } catch {\n return undefined;\n }\n}\n\n/** The caller's own submissions, newest first. Never anybody else's. */\nexport async function fetchMySubmissions(): Promise<SubmissionResult<TSubmission[]>> {\n try {\n const res = await fetch(url('/api/submissions/mine'), { cache: 'no-store' });\n if (!res.ok) {\n return { ok: false, failure: classifySubmissionFailure(res.status, await safeJson(res)) };\n }\n const body = (await res.json()) as { submissions?: TSubmission[] };\n return { ok: true, value: Array.isArray(body.submissions) ? body.submissions : [] };\n } catch (err: unknown) {\n warn('submissions', 'List threw', errorFields(err));\n return { ok: false, failure: { code: 'network' } };\n }\n}\n\n/**\n * A failed moderation call as a short STATE line for the operator.\n *\n * A 409 here is not an error in the operator's own action, it is the normal\n * way to meet a stale queue page: another operator (or another tab) moved the\n * row first. Saying \"already approved\" would be wrong half the time — it may\n * have been rejected — and naming neither state hides that the page is behind,\n * so the line says who got there first and the row is dropped from the list.\n * The 404 is the same situation one step further on (the row is gone).\n */\nexport function decisionFailureLabel(failure: SubmissionFailure): string {\n switch (failure.code) {\n case 'conflict': return 'someone else already decided this';\n case 'not_found': return 'no longer exists';\n default: return submissionFailureLabel(failure);\n }\n}\n\n/** Whether a failed decision means the row is no longer `pending` server-side. */\nexport function decisionMeansRowMoved(failure: SubmissionFailure): boolean {\n return failure.code === 'conflict' || failure.code === 'not_found';\n}\n\n/** Age as one coarse unit: `45s`, `12m`, `3h`, `2d`. Never negative. */\nexport function formatSubmissionAge(createdAt: number, now: number): string {\n const s = Math.max(0, Math.floor((now - createdAt) / 1000));\n if (s < 60) return `${s}s`;\n if (s < 3600) return `${Math.floor(s / 60)}m`;\n if (s < 86400) return `${Math.floor(s / 3600)}h`;\n return `${Math.floor(s / 86400)}d`;\n}\n\n/** The operator queue: every `pending` row, oldest first (service tier). */\nexport async function fetchPendingSubmissions(): Promise<SubmissionResult<TSubmission[]>> {\n try {\n const res = await fetch(url('/api/submissions?state=pending'), { cache: 'no-store' });\n if (!res.ok) {\n return { ok: false, failure: classifySubmissionFailure(res.status, await safeJson(res)) };\n }\n const body = (await res.json()) as { submissions?: TSubmission[] };\n const rows = Array.isArray(body.submissions) ? body.submissions : [];\n return { ok: true, value: [...rows].sort((a, b) => a.createdAt - b.createdAt) };\n } catch (err: unknown) {\n warn('submissions', 'Queue list threw', errorFields(err));\n return { ok: false, failure: { code: 'network' } };\n }\n}\n\nasync function decide(\n id: string, verb: 'approve' | 'reject', body?: { reason: string },\n): Promise<SubmissionResult<TSubmission>> {\n try {\n const res = await fetch(url(`/api/submissions/${encodeURIComponent(id)}/${verb}`), {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(body ?? {}),\n });\n if (!res.ok) {\n const failure = classifySubmissionFailure(res.status, await safeJson(res));\n debug('submissions', 'Decision refused', { verb, status: res.status, code: failure.code });\n return { ok: false, failure };\n }\n const parsed = (await res.json()) as { submission: TSubmission };\n return { ok: true, value: parsed.submission };\n } catch (err: unknown) {\n warn('submissions', 'Decision threw', errorFields(err));\n return { ok: false, failure: { code: 'network' } };\n }\n}\n\nexport function approveSubmission(id: string): Promise<SubmissionResult<TSubmission>> {\n return decide(id, 'approve');\n}\n\n/** A blank reason is omitted: the server's schema refuses an empty string. */\nexport function rejectSubmission(id: string, reason: string): Promise<SubmissionResult<TSubmission>> {\n const trimmed = reason.trim();\n return decide(id, 'reject', trimmed ? { reason: trimmed } : undefined);\n}\n\n// ── Uploads: filetransfer chunk upload ──────────────────────────────────────\n//\n// The server never hands the browser its own SERVICE_KEY — `upload-target`\n// resolves a recipient userId and a cap server-side (see\n// `http/routes/submissions.ts`). What the browser does with them is Gokapi's\n// OWN chunked-upload protocol, verified against filetransfer's own browser\n// client (`hadoku-filetransfer/packages/ui/src/lib/upload.ts`) rather than\n// guessed: `POST /uploadChunk` per slice (session-authed via the\n// `hadoku_session` cookie — same-origin with this app's own apex, so\n// `credentials: 'include'` is what carries it, same as that client), then\n// `GET /auth/token` for a short-lived apikey, then `POST /api/chunk/complete`\n// with the assembly parameters as HEADERS (not form fields — Gokapi's API\n// routes read `r.Header.Get`) plus `X-Hadoku-Recipient`, which is what makes\n// this a DIRECT transfer rather than an ordinary link-shared file.\n\n/** What the server resolved for this upload. */\nexport interface UploadTarget { recipient: string; maxBytes: number; uploadUrl: string }\n\n/** `GET /api/submissions/upload-target` — friend tier. */\nexport async function fetchUploadTarget(): Promise<SubmissionResult<UploadTarget>> {\n try {\n const res = await fetch(url('/api/submissions/upload-target'), { cache: 'no-store' });\n if (!res.ok) {\n return { ok: false, failure: classifySubmissionFailure(res.status, await safeJson(res)) };\n }\n const body = (await res.json()) as Partial<UploadTarget>;\n if (\n typeof body.recipient !== 'string' || !body.recipient ||\n typeof body.maxBytes !== 'number' || typeof body.uploadUrl !== 'string' || !body.uploadUrl\n ) {\n return { ok: false, failure: { code: 'unknown', status: res.status } };\n }\n return { ok: true, value: { recipient: body.recipient, maxBytes: body.maxBytes, uploadUrl: body.uploadUrl } };\n } catch (err: unknown) {\n warn('submissions', 'Upload target fetch threw', errorFields(err));\n return { ok: false, failure: { code: 'network' } };\n }\n}\n\n/** Why a chunk upload did not produce a filetransfer file id. */\nexport type UploadFailure =\n | { code: 'too_large'; maxBytes: number }\n | { code: 'target_unavailable' }\n | { code: 'upload_failed'; message: string };\n\nexport type UploadResult<T> = { ok: true; value: T } | { ok: false; failure: UploadFailure };\n\nexport function uploadFailureLabel(failure: UploadFailure): string {\n switch (failure.code) {\n case 'too_large': return `over ${formatSubmissionBytes(failure.maxBytes)} cap`;\n case 'target_unavailable': return 'upload unavailable';\n case 'upload_failed': return 'upload failed';\n }\n}\n\nexport type UploadPhase = 'uploading' | 'assembling';\n\n/** Per-request slice size: above Gokapi's 5 MB floor for a non-final chunk,\n * below Cloudflare's 100 MB single-request body ceiling. Matches\n * filetransfer's own client, which measured both bounds against the live\n * backend. */\nconst UPLOAD_CHUNK_BYTES = 40 * 1024 * 1024;\nconst UPLOAD_REQUEST_TIMEOUT_MS = 10 * 60 * 1000;\nconst UPLOAD_ATTEMPTS = 5;\n/** This is a machine-to-machine staging area, not a shared download link —\n * generous enough that neither bound is what makes the transfer fail before\n * the runner (wave 4, lane A) collects it. */\nconst UPLOAD_ALLOWED_DOWNLOADS = 50;\nconst UPLOAD_EXPIRY_DAYS = 14;\n\n/** A response retrying cannot fix — a 4xx means the request itself is wrong. */\nclass FatalUploadError extends Error {}\n\nasync function uploadFetchWithRetry(input: string, init: RequestInit, what: string): Promise<Response> {\n let last: unknown;\n for (let attempt = 0; attempt < UPLOAD_ATTEMPTS; attempt++) {\n if (attempt > 0) {\n await new Promise(r => setTimeout(r, Math.min(1000 * 2 ** (attempt - 1), 8000)));\n }\n try {\n const res = await fetch(input, { ...init, signal: AbortSignal.timeout(UPLOAD_REQUEST_TIMEOUT_MS) });\n if (res.ok) return res;\n if (res.status < 500 && res.status !== 429) {\n throw new FatalUploadError(`${what} failed (${res.status})`);\n }\n last = new Error(`${what} failed (${res.status})`);\n } catch (err: unknown) {\n if (err instanceof FatalUploadError) throw err;\n last = err;\n }\n }\n const detail = last instanceof Error ? last.message : String(last);\n throw new Error(`${what} failed after ${UPLOAD_ATTEMPTS} attempts — ${detail}`);\n}\n\n/** UTF-8-aware base64, matching what Gokapi's `base64:` header prefix decodes\n * (`btoa` alone throws on any character outside Latin-1, which a unicode\n * filename reliably has). */\nfunction b64(s: string): string {\n return btoa(String.fromCharCode(...new TextEncoder().encode(s)));\n}\n\nasync function mintUploadToken(uploadUrl: string): Promise<string> {\n const res = await fetch(`${uploadUrl}/auth/token`, {\n credentials: 'include',\n headers: { permission: 'PERM_UPLOAD' },\n });\n if (!res.ok) throw new FatalUploadError(`could not obtain an upload token (${res.status})`);\n const body = (await res.json()) as { key?: string };\n if (!body.key) throw new Error('token response had no key');\n return body.key;\n}\n\n/**\n * Chunk-upload `file` to filetransfer, addressed to `target.recipient`, and\n * return the filetransfer file id a submission's `source` should carry.\n *\n * The 4 GB cap is checked here BEFORE any network call — a courtesy only; the\n * server enforces it authoritatively on bytes actually received (wave 4,\n * lane A), because the size reported here is advisory.\n */\nexport async function uploadFileForSubmission(\n file: File,\n target: UploadTarget,\n onProgress?: (sent: number, total: number, phase: UploadPhase) => void,\n): Promise<UploadResult<{ fileId: string; bytes: number }>> {\n if (file.size > target.maxBytes) {\n return { ok: false, failure: { code: 'too_large', maxBytes: target.maxBytes } };\n }\n try {\n const id = crypto.randomUUID();\n const total = file.size;\n onProgress?.(0, total, 'uploading');\n\n for (let offset = 0; offset < total || (total === 0 && offset === 0); offset += UPLOAD_CHUNK_BYTES) {\n const slice = file.slice(offset, Math.min(offset + UPLOAD_CHUNK_BYTES, total));\n const form = new FormData();\n // Dropzone's own field names — Gokapi's non-API chunk endpoint parses\n // these with `isApiCall=false`, not the header form step 3 uses.\n form.append('dzuuid', id);\n form.append('dzchunkbyteoffset', String(offset));\n form.append('dztotalfilesize', String(total));\n form.append('file', slice, file.name);\n await uploadFetchWithRetry(\n `${target.uploadUrl}/uploadChunk`,\n { method: 'POST', credentials: 'include', body: form },\n `sending the part at ${Math.round(offset / 1024 / 1024)} MB`,\n );\n onProgress?.(Math.min(offset + UPLOAD_CHUNK_BYTES, total), total, 'uploading');\n if (total === 0) break;\n }\n\n onProgress?.(total, total, 'assembling');\n\n // Fresh key per attempt, same as filetransfer's own client: a key that\n // aged out mid-retry should not be reused by the next attempt.\n let res: Response | null = null;\n let lastErr: unknown;\n for (let attempt = 0; attempt < 2 && !res; attempt++) {\n const key = await mintUploadToken(target.uploadUrl);\n const headers: Record<string, string> = {\n uuid: id,\n filename: `base64:${b64(file.name)}`,\n filesize: String(total),\n contenttype: file.type || 'application/octet-stream',\n allowedDownloads: String(UPLOAD_ALLOWED_DOWNLOADS),\n expiryDays: String(UPLOAD_EXPIRY_DAYS),\n apikey: key,\n // The direct-transfer binding. The shim intercepts exactly this call\n // when this header is present and records (owner, recipient) against\n // the file id in its own response body.\n 'X-Hadoku-Recipient': target.recipient,\n };\n try {\n res = await uploadFetchWithRetry(\n `${target.uploadUrl}/api/chunk/complete`,\n { method: 'POST', credentials: 'include', headers },\n 'assembling the upload',\n );\n } catch (err: unknown) {\n lastErr = err;\n if (!(err instanceof FatalUploadError && /\\((401|403)\\)/.test(err.message))) throw err;\n }\n }\n if (!res) throw lastErr instanceof Error ? lastErr : new Error(String(lastErr));\n\n const body = (await res.json()) as { FileInfo?: { Id?: string; SizeBytes?: number } };\n if (!body.FileInfo?.Id) {\n return { ok: false, failure: { code: 'upload_failed', message: 'no file info returned' } };\n }\n const bytes = typeof body.FileInfo.SizeBytes === 'number' ? body.FileInfo.SizeBytes : total;\n return { ok: true, value: { fileId: body.FileInfo.Id, bytes } };\n } catch (err: unknown) {\n warn('submissions', 'Upload threw', errorFields(err));\n const message = err instanceof Error ? err.message : String(err);\n return { ok: false, failure: { code: 'upload_failed', message } };\n }\n}\n"],"names":["guessSubmissionKind","raw","readNumber","body","keys","key","v","readString","classifySubmissionFailure","status","obj","error","pending","cap","UNITS","formatSubmissionBytes","bytes","n","u","submissionFailureLabel","failure","SUBMIT_TIERS","canSubmitMedia","getUserType","url","path","getRuntimeServerOrigin","safeJson","res","postSubmission","input","debug","parsed","err","warn","errorFields","submitSubmission","rawSource","title","source","kind","submitMagnetSubmission","opts","submitUploadSubmission","fileId","SXXEXX_RE","hasEpisodeCode","extractMagnetDisplayName","trimmed","fetchMySubmissions","decisionFailureLabel","decisionMeansRowMoved","formatSubmissionAge","createdAt","now","s","fetchPendingSubmissions","a","b","decide","id","verb","approveSubmission","rejectSubmission","reason","fetchUploadTarget","uploadFailureLabel","UPLOAD_CHUNK_BYTES","UPLOAD_REQUEST_TIMEOUT_MS","UPLOAD_ATTEMPTS","UPLOAD_ALLOWED_DOWNLOADS","UPLOAD_EXPIRY_DAYS","FatalUploadError","uploadFetchWithRetry","init","what","last","attempt","r","detail","b64","mintUploadToken","uploadUrl","uploadFileForSubmission","file","target","onProgress","total","offset","slice","form","lastErr","headers"],"mappings":"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"}
|
package/dist/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hadoku/watchparty-ui",
|
|
3
|
-
"version": "0.45.
|
|
3
|
+
"version": "0.45.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -32,11 +32,11 @@
|
|
|
32
32
|
"dependencies": {
|
|
33
33
|
"@wolffm/cdn-client": "npm:@hadoku/cdn-client@^2.0.3",
|
|
34
34
|
"@wolffm/prefs-client": "npm:@hadoku/prefs-client@^1.1.7",
|
|
35
|
-
"@wolffm/themes": "npm:@hadoku/themes@^5.13.
|
|
35
|
+
"@wolffm/themes": "npm:@hadoku/themes@^5.13.13",
|
|
36
36
|
"socket.io-client": "^4.8.3",
|
|
37
37
|
"zod": "^4.0.0",
|
|
38
38
|
"zustand": "^5.0.10",
|
|
39
|
-
"@wolffm/rooms-protocol": "npm:@hadoku/rooms-protocol@0.45.
|
|
39
|
+
"@wolffm/rooms-protocol": "npm:@hadoku/rooms-protocol@0.45.4"
|
|
40
40
|
},
|
|
41
41
|
"peerDependencies": {
|
|
42
42
|
"@wolffm/task-ui-components": ">=4.8.3",
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"PlaylistPanel-2V6H_3Gw.js","sources":["../../src/components/PlaylistRevealPopup.tsx","../../src/components/PlaylistPanel.tsx"],"sourcesContent":["import { useEffect, useState, type ReactNode } from 'react';\nimport type { TPlaylistResultsOut, TVoteValue } from '@watchparty/shared';\nimport { useAppStore } from '../lib/store';\nimport { spriteUrlFor, colorForSprite, isCharacterId } from '../lib/characters';\nimport { StarIcon } from './controls/Icons';\nimport { useIsLeader } from '../hooks/useStoreSelectors';\nimport { emitVoteRemove } from '../lib/socket';\n\n/** How long the ✕ stays armed after the first click before it disarms. */\nconst REMOVE_ARM_MS = 3000;\n\ninterface Props {\n results: TPlaylistResultsOut;\n onClose: () => void;\n}\n\nconst VOTE_WEIGHTS: Record<TVoteValue, number> = {\n super_upvote: 4,\n upvote: 3,\n mid: 2,\n downvote: 1,\n};\n\nconst VOTE_ICONS: Record<TVoteValue, { icon: ReactNode; color: string }> = {\n // Bigger than the other marks on purpose: a super-upvote is the strongest\n // vote and was the hardest to see (a 10px star under the avatar).\n super_upvote: { icon: <StarIcon className=\"w-4 h-4\" />, color: 'bg-warning-bg text-on-warning-bg rounded' },\n upvote: { icon: '▲', color: 'bg-success-bg text-on-success-bg rounded px-0.5' },\n mid: { icon: '─', color: 'text-text-secondary' },\n downvote: { icon: '▼', color: 'bg-danger-bg text-on-danger-bg rounded px-0.5' },\n};\n\nfunction computeScores(results: TPlaylistResultsOut) {\n const { playlist, votes } = results;\n return playlist.videos.map(video => {\n let totalScore = 0;\n let voterCount = 0;\n for (const clientVotes of Object.values(votes)) {\n const v = clientVotes[video.mediaId] as TVoteValue | undefined;\n if (v) { totalScore += VOTE_WEIGHTS[v]; voterCount++; }\n }\n const avgScore = voterCount > 0 ? totalScore / voterCount : 0;\n return { video, avgScore, voterCount };\n }).sort((a, b) => b.avgScore - a.avgScore);\n}\n\nexport default function PlaylistRevealPopup({ results, onClose }: Props) {\n const roster = useAppStore(s => s.roster);\n const isLeader = useIsLeader();\n const scored = computeScores(results);\n // Two clicks to remove: the first arms the ✕, the second sends. A stray\n // click mid-party must not silently drop someone's ballot.\n const [armed, setArmed] = useState<string | null>(null);\n useEffect(() => {\n if (!armed) return;\n const t = setTimeout(() => setArmed(null), REMOVE_ARM_MS);\n return () => clearTimeout(t);\n }, [armed]);\n\n // Ordered list of clients who voted\n const voters = Object.keys(results.votes).filter(\n id => Object.keys(results.votes[id]).length > 0\n );\n // Frozen at reveal by the server, so someone who has since LEFT (and is\n // gone from the live roster) still has an avatar.\n const spriteOf = (id: string) => {\n const sprite = roster[id]?.sprite ?? results.sprites?.[id];\n return isCharacterId(sprite) ? sprite : undefined;\n };\n\n function onRemove(voterKey: string) {\n if (armed !== voterKey) { setArmed(voterKey); return; }\n setArmed(null);\n emitVoteRemove(voterKey);\n }\n\n return (\n <div\n className=\"fixed inset-0 bg-black/70 flex items-center justify-center z-50\"\n onClick={e => { if (e.target === e.currentTarget) onClose(); }}\n >\n <div data-testid=\"reveal-popup\" className=\"bg-bg-card border border-border rounded-lg shadow-xl w-full max-w-2xl max-h-[80vh] flex flex-col mx-4\">\n {/* Header */}\n <div className=\"flex items-center justify-between px-4 py-3 border-b border-border shrink-0\">\n <h2 className=\"text-sm font-semibold text-text\">\n {results.playlist.name} — Results\n </h2>\n <button\n onClick={onClose}\n aria-label=\"Close results\"\n className=\"p-1 text-text-muted hover:text-text transition-colors cursor-pointer\"\n >\n <svg className=\"w-4 h-4\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\" strokeWidth={2}>\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M6 18L18 6M6 6l12 12\" />\n </svg>\n </button>\n </div>\n\n {/* Voters — the leader can drop one; the table regenerates for everyone */}\n {voters.length > 0 && (\n <div className=\"flex flex-wrap items-center gap-1.5 px-4 py-2 border-b border-border shrink-0\">\n {voters.map(id => {\n const spriteId = spriteOf(id);\n return (\n <span key={id} className=\"reveal-voter\">\n {spriteId\n ? <img src={spriteUrlFor(spriteId)} alt=\"\" crossOrigin=\"anonymous\" className=\"w-4 h-4 rounded-full object-cover\" />\n : <span className=\"w-4 h-4 rounded-full bg-bg-alt\" />}\n <span className=\"text-xs text-text truncate max-w-[8rem]\">{results.names[id] || id}</span>\n {isLeader && (\n <button\n type=\"button\"\n className=\"reveal-voter-remove\"\n data-armed={armed === id ? '' : undefined}\n onClick={() => onRemove(id)}\n aria-label={`Remove ${results.names[id] || id}'s votes`}\n title={armed === id ? 'Click again to remove' : `Remove ${results.names[id] || id}'s votes`}\n >\n {armed === id ? 'remove?' : '✕'}\n </button>\n )}\n </span>\n );\n })}\n </div>\n )}\n\n {/* Results list */}\n <div className=\"overflow-y-auto flex-1 py-1\">\n {scored.map(({ video, avgScore, voterCount }, rank) => (\n <div\n key={video.mediaId}\n className={`flex items-center gap-3 px-4 py-2 ${rank % 2 === 0 ? '' : 'bg-bg-alt/20'}`}\n >\n {/* Rank */}\n <span className=\"text-xs text-text-muted w-5 shrink-0 text-right\">{rank + 1}</span>\n\n {/* Video name */}\n <span className=\"text-xs text-text truncate flex-1 min-w-0\">\n {video.displayName}\n </span>\n\n {/* Per-voter avatars + vote icon */}\n <div className=\"flex items-center gap-1.5 shrink-0\">\n {voters.map(clientId => {\n const vote = results.votes[clientId]?.[video.mediaId] as TVoteValue | undefined;\n if (!vote) return null;\n const spriteId = spriteOf(clientId);\n const voteStyle = VOTE_ICONS[vote];\n const borderColor = colorForSprite(spriteId ?? null);\n\n return (\n <div key={clientId} className=\"flex flex-col items-center gap-0.5\">\n {/* Avatar */}\n <div className=\"relative w-6 h-6 shrink-0\">\n {spriteId ? (\n <img\n src={spriteUrlFor(spriteId)}\n alt={results.names[clientId] || clientId}\n crossOrigin=\"anonymous\"\n className=\"w-6 h-6 rounded-full object-cover\"\n style={{ border: `1.5px solid ${borderColor}` }}\n />\n ) : (\n <div\n className=\"w-6 h-6 rounded-full bg-bg-alt\"\n style={{ border: `1.5px solid ${borderColor}` }}\n />\n )}\n </div>\n {/* Vote icon */}\n <span className={`text-[9px] leading-none font-bold ${voteStyle.color}`}>\n {voteStyle.icon}\n </span>\n </div>\n );\n })}\n {/* Show dashes for non-voters */}\n {voters.map(clientId => {\n const voted = !!results.votes[clientId]?.[video.mediaId];\n if (voted) return null;\n return (\n <div key={`no-${clientId}`} className=\"flex flex-col items-center gap-0.5 opacity-20\">\n <div className=\"w-6 h-6 rounded-full bg-bg-alt border border-border\" />\n <span className=\"text-[9px] leading-none text-text-muted\">·</span>\n </div>\n );\n })}\n </div>\n\n {/* Avg score */}\n <span className=\"text-xs text-text-secondary w-8 text-right shrink-0 font-medium\">\n {voterCount > 0 ? avgScore.toFixed(1) : '—'}\n </span>\n </div>\n ))}\n </div>\n </div>\n </div>\n );\n}\n","import React, { useState } from 'react';\nimport { useAppStore } from '../lib/store';\nimport { useIsLeader } from '../hooks/useStoreSelectors';\nimport { emitPlaylistLoad, emitPlaylistSelect, emitVoteUpdate, emitRevealRequest } from '../lib/socket';\nimport { resolveMediaAsset, resolveCardImage } from '../lib/mediaUrls';\nimport { debug } from '../lib/debug';\nimport { STORAGE_KEYS } from '../lib/storageKeys';\nimport { voteOwnerScope } from '../lib/auth';\nimport type { TVoteValue } from '@watchparty/shared';\nimport PlaylistRevealPopup from './PlaylistRevealPopup';\nimport { StarIcon } from './controls/Icons';\nimport { readStored, writeStored } from '../lib/browserStorage';\n\n// ── Vote icons ────────────────────────────────────────────────────────────────\n\n\nfunction UpvoteIcon() {\n return (\n <svg className=\"w-3.5 h-3.5\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth={2.5}>\n <polyline points=\"18 15 12 9 6 15\" />\n </svg>\n );\n}\n\nfunction MidIcon() {\n return (\n <svg className=\"w-3.5 h-3.5\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth={2.5}>\n <line x1=\"5\" y1=\"12\" x2=\"19\" y2=\"12\" />\n </svg>\n );\n}\n\nfunction DownvoteIcon() {\n return (\n <svg className=\"w-3.5 h-3.5\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth={2.5}>\n <polyline points=\"6 9 12 15 18 9\" />\n </svg>\n );\n}\n\n// Colours live in `.vote-btn` (styles.css), keyed by `data-vote`: a <button>'s\n// colour/background utilities are discarded by the unlayered `button` reset, so\n// the per-vote `text-*` classes these carried never rendered — every vote icon\n// was the same white, selected or not.\nconst VOTE_OPTIONS: { value: TVoteValue; icon: React.ReactNode; label: string }[] = [\n { value: 'super_upvote', icon: <StarIcon className=\"w-4 h-4\" />, label: 'Super' },\n { value: 'upvote', icon: <UpvoteIcon />, label: 'Up' },\n { value: 'mid', icon: <MidIcon />, label: 'Mid' },\n { value: 'downvote', icon: <DownvoteIcon />, label: 'Down' },\n];\n\n// ── Helpers ───────────────────────────────────────────────────────────────────\n\nfunction formatDuration(ms: number): string {\n const total = Math.round(ms / 1000);\n const h = Math.floor(total / 3600);\n const m = Math.floor((total % 3600) / 60);\n const s = total % 60;\n return h > 0\n ? `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`\n : `${m}:${String(s).padStart(2, '0')}`;\n}\n\n// ── Load form (leader, no playlist loaded) ────────────────────────────────────\n\nfunction LoadForm() {\n const [input, setInput] = useState('');\n const [loading, setLoading] = useState(false);\n const [err, setErr] = useState<string | null>(null);\n\n const handleLoad = () => {\n const id = input.trim();\n if (!id) return;\n setLoading(true);\n setErr(null);\n emitPlaylistLoad(id);\n // Error will arrive via the 'error' socket event and show as a toast.\n // Reset loading state after a short delay.\n setTimeout(() => setLoading(false), 1500);\n };\n\n return (\n <div className=\"p-4 flex flex-col gap-3\">\n <p className=\"text-xs text-text-muted\">Enter a playlist ID to load it for the room.</p>\n <div className=\"flex gap-2\">\n <input\n type=\"text\"\n value={input}\n onChange={e => setInput(e.target.value)}\n onKeyDown={e => e.key === 'Enter' && handleLoad()}\n placeholder=\"Playlist ID (e.g. april-4)\"\n className=\"modal-input flex-1 text-xs px-2 py-1.5\"\n disabled={loading}\n />\n <button\n onClick={handleLoad}\n disabled={loading || !input.trim()}\n className=\"modal-btn-update text-xs px-3 py-1.5 rounded disabled:opacity-50 disabled:cursor-not-allowed\"\n >\n Load\n </button>\n </div>\n {err && <p className=\"text-xs bg-danger-bg text-on-danger-bg px-2 py-1 rounded\">{err}</p>}\n </div>\n );\n}\n\n// ── Empty state (viewer, no playlist) ─────────────────────────────────────────\n\nfunction EmptyState() {\n return (\n <div className=\"flex-1 flex items-center justify-center p-4\">\n <p className=\"text-xs text-text-muted text-center\">No playlist loaded yet.</p>\n </div>\n );\n}\n\n// ── Main panel ────────────────────────────────────────────────────────────────\n\nexport default function PlaylistPanel() {\n const isLeader = useIsLeader();\n const playlist = useAppStore(s => s.playlist);\n const activeIndex = useAppStore(s => s.activePlaylistIndex);\n const myVotes = useAppStore(s => s.myVotes);\n const voteProgress = useAppStore(s => s.voteProgress);\n const results = useAppStore(s => s.playlistResults);\n const currentMediaId = useAppStore(s => s.snapshot.mediaId);\n\n // Show results popup when a reveal arrives. NOT on an `update` (a voter was\n // removed): that re-sends the table to everyone, and reopening it would put\n // the popup back in front of people who had already closed it.\n const [showResults, setShowResults] = useState(() => !!results && !results.update);\n const [seenResults, setSeenResults] = useState(results);\n if (results !== seenResults) {\n setSeenResults(results);\n if (results && !results.update) setShowResults(true);\n }\n\n // Restore votes from localStorage when a playlist is first loaded\n React.useEffect(() => {\n if (!playlist || playlist.type !== 'vote') return;\n const savedVotes: Record<string, TVoteValue> = {};\n for (const video of playlist.videos) {\n const key = STORAGE_KEYS.voteKey(voteOwnerScope(), playlist.id, video.mediaId);\n const saved = readStored(key) as TVoteValue | null;\n if (saved && VOTE_OPTIONS.some(o => o.value === saved)) {\n savedVotes[video.mediaId] = saved;\n }\n }\n debug('playlist', 'Checked localStorage votes', { playlistId: playlist.id, found: Object.keys(savedVotes).length });\n if (Object.keys(savedVotes).length === 0) return;\n useAppStore.getState().applyPlaylistState({ playlist, activeIndex, myVotes: { ...myVotes, ...savedVotes } });\n for (const [mediaId, vote] of Object.entries(savedVotes)) {\n emitVoteUpdate(playlist.id, mediaId, vote);\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [playlist?.id]);\n\n if (!playlist) {\n return isLeader ? <LoadForm /> : <EmptyState />;\n }\n\n // `basic` playlists are a plain watch-through — no vote buttons, progress,\n // or reveal. Only `vote` playlists show the rating mechanic.\n const isVoting = playlist.type === 'vote';\n const totalUsers = voteProgress.length;\n\n const handleVote = (mediaId: string, vote: TVoteValue) => {\n emitVoteUpdate(playlist.id, mediaId, vote);\n debug('playlist', 'Vote emitted', { playlistId: playlist.id, mediaId, vote });\n writeStored(STORAGE_KEYS.voteKey(voteOwnerScope(), playlist.id, mediaId), vote);\n\n // Mirror the server's super_upvote exclusivity rule: if this vote is a star,\n // downgrade any other video's star to upvote so the visual ring updates immediately.\n const updatedVotes = { ...myVotes, [mediaId]: vote };\n if (vote === 'super_upvote') {\n for (const [otherId, otherVote] of Object.entries(updatedVotes)) {\n if (otherId !== mediaId && otherVote === 'super_upvote') {\n updatedVotes[otherId] = 'upvote';\n writeStored(STORAGE_KEYS.voteKey(voteOwnerScope(), playlist.id, otherId), 'upvote');\n }\n }\n }\n\n useAppStore.getState().applyPlaylistState({\n playlist,\n activeIndex,\n myVotes: updatedVotes,\n });\n };\n\n return (\n <div className=\"flex flex-col flex-1 min-h-0\">\n {/* Header */}\n <div className=\"flex items-center gap-2 px-3 py-2 border-b border-border shrink-0\">\n {playlist.cover && (\n <img\n src={resolveMediaAsset(playlist.cover)}\n alt=\"\"\n className=\"w-8 h-8 object-cover rounded shrink-0 bg-bg-alt\"\n />\n )}\n <span className=\"text-xs font-medium text-text truncate flex-1\">\n {playlist.name}\n </span>\n {isLeader && isVoting && (\n <button\n onClick={() => emitRevealRequest()}\n className=\"text-xs text-text-muted hover:text-text transition-colors cursor-pointer shrink-0\"\n title=\"Reveal votes to all\"\n >\n Reveal\n </button>\n )}\n </div>\n\n {/* Vote progress bar (leader only, vote playlists only) */}\n {isLeader && isVoting && totalUsers > 0 && (\n <div className=\"px-3 py-1.5 border-b border-border shrink-0\">\n <div className=\"flex items-center gap-1.5\">\n <span className=\"text-[10px] text-text-muted shrink-0\">Votes:</span>\n <div className=\"flex gap-1 flex-wrap flex-1\">\n {voteProgress.map(p => (\n <span key={p.clientId} className=\"text-[10px] text-text-secondary\">\n {p.name} {p.votedCount}/{p.totalVideos}\n </span>\n ))}\n </div>\n </div>\n </div>\n )}\n\n {/* Video list */}\n <div className=\"flex-1 overflow-y-auto\">\n {playlist.videos.map((video, i) => {\n const isActive = activeIndex === i;\n const myVote = myVotes[video.mediaId] as TVoteValue | undefined;\n const isCurrentMedia = currentMediaId === video.mediaId;\n\n return (\n <div\n key={video.mediaId}\n className={`flex items-center gap-2 px-3 py-2 border-b border-border/50 group ${\n isActive ? 'bg-primary/10' : 'hover:bg-bg-alt/50'\n }`}\n >\n {/* Play indicator */}\n <div className=\"w-3.5 shrink-0 text-text-muted\">\n {isCurrentMedia ? (\n <svg className=\"w-3.5 h-3.5 text-text\" viewBox=\"0 0 24 24\" fill=\"currentColor\">\n <polygon points=\"5 3 19 12 5 21 5 3\" />\n </svg>\n ) : (\n <span className=\"text-[10px] text-text-muted\">{i + 1}</span>\n )}\n </div>\n\n {/* Thumbnail. No longer flat-playlist-only: `poster.jpg` is\n derived by convention when a clip carries no explicit\n thumbnail (see resolveCardImage), and a 404 hides the image. */}\n <div className=\"relative shrink-0\">\n <img\n src={resolveCardImage(video.mediaId, video.thumbnail)}\n alt=\"\"\n loading=\"lazy\"\n onError={e => {\n const el = e.currentTarget as HTMLImageElement;\n // Hide the whole slot, not just the img — the duration\n // badge is positioned against it.\n (el.parentElement ?? el).style.display = 'none';\n }}\n className=\"w-14 h-10 object-cover rounded bg-bg-alt\"\n />\n {video.durationMs ? (\n <span className=\"absolute bottom-0 right-0 px-1 text-[9px] leading-tight rounded-tl rounded-br bg-bg-card/90 text-text-secondary\">\n {formatDuration(video.durationMs)}\n </span>\n ) : null}\n </div>\n\n {/* Video name — leader can click to select */}\n <button\n className={`flex-1 text-left text-xs truncate cursor-pointer transition-colors min-w-0 ${\n isLeader\n ? 'hover:text-text text-text-secondary'\n : 'text-text-secondary cursor-default'\n } ${isActive ? 'text-text font-medium' : ''}`}\n onClick={isLeader ? () => emitPlaylistSelect(i) : undefined}\n title={isLeader ? `Play: ${video.displayName}` : video.displayName}\n disabled={!isLeader}\n >\n {video.displayName}\n </button>\n\n {/* Vote buttons (vote playlists only) */}\n {isVoting && (\n <div className=\"flex gap-0.5 shrink-0\">\n {VOTE_OPTIONS.map(opt => {\n const isSelected = myVote === opt.value;\n return (\n <button\n key={opt.value}\n onClick={() => handleVote(video.mediaId, opt.value)}\n title={opt.label}\n className=\"vote-btn\"\n data-vote={opt.value}\n data-selected={isSelected ? '' : undefined}\n aria-pressed={isSelected}\n >\n {opt.icon}\n </button>\n );\n })}\n </div>\n )}\n </div>\n );\n })}\n </div>\n\n {/* Results popup (vote playlists only) */}\n {isVoting && showResults && results && (\n <PlaylistRevealPopup\n results={results}\n onClose={() => setShowResults(false)}\n />\n )}\n </div>\n );\n}\n"],"names":["REMOVE_ARM_MS","VOTE_WEIGHTS","VOTE_ICONS","jsx","StarIcon","computeScores","results","playlist","votes","video","totalScore","voterCount","clientVotes","v","avgScore","a","b","PlaylistRevealPopup","onClose","roster","useAppStore","isLeader","useIsLeader","scored","armed","setArmed","useState","useEffect","t","voters","id","spriteOf","sprite","isCharacterId","onRemove","voterKey","emitVoteRemove","e","jsxs","spriteId","spriteUrlFor","rank","clientId","vote","voteStyle","borderColor","colorForSprite","UpvoteIcon","MidIcon","DownvoteIcon","VOTE_OPTIONS","formatDuration","ms","total","h","m","s","LoadForm","input","setInput","loading","setLoading","err","setErr","handleLoad","emitPlaylistLoad","EmptyState","PlaylistPanel","activeIndex","myVotes","voteProgress","currentMediaId","showResults","setShowResults","seenResults","setSeenResults","React","savedVotes","key","STORAGE_KEYS","voteOwnerScope","saved","readStored","o","debug","mediaId","emitVoteUpdate","isVoting","totalUsers","handleVote","writeStored","updatedVotes","otherId","otherVote","resolveMediaAsset","emitRevealRequest","p","i","isActive","myVote","isCurrentMedia","resolveCardImage","el","emitPlaylistSelect","opt","isSelected"],"mappings":"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"}
|