Deployment (t4g2): media-worker gains font-dejavu (drawtext lower-thirds) and a dedicated produce-worker compose service running src/produce.ts — claims video_processing_jobs compose|mix by lane, renders assembled/ overlaid/voiced cuts, registers derived library assets. Deliberately its own process so a remote-lane box needs only Postgres + S3 + ffmpeg. Web: compose media attachments UI (t2), Producer panel in the video drawer (t5), collective creation + branding settings + shell branding (brandweb), mobile CSS (mcss: touch targets, icon sizing, safe areas).
174 lines
5.9 KiB
TypeScript
174 lines
5.9 KiB
TypeScript
"use client";
|
|
|
|
import { createContext, createElement, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
|
|
import { API_URL, api, apiFetch, apiUploadFile, ApiUnreachableError, errorMessage } from "./api-client";
|
|
import { useAuth } from "./auth-store";
|
|
|
|
/**
|
|
* Per-collective branding on the client — GET /workspace/branding at shell
|
|
* boot (any active member), PUT + multipart logo upload from Settings
|
|
* (workspace.members.manage). The constants mirror
|
|
* apps/api/src/settings/branding.service.ts (migration 036) — the four keys,
|
|
* the 160-char tagline cap, #RGB/#RRGGBB accent, 4 MB image cap — so the form
|
|
* refuses locally what the API would reject anyway.
|
|
*/
|
|
|
|
export interface BrandingPatch {
|
|
logoAssetKey?: string | null;
|
|
tagline?: string | null;
|
|
backgroundKey?: string | null;
|
|
accent?: string | null;
|
|
}
|
|
|
|
/** What the API stores — keys and text only, no URLs. */
|
|
export interface BrandingRecord {
|
|
logoAssetKey: string | null;
|
|
tagline: string | null;
|
|
backgroundKey: string | null;
|
|
accent: string | null;
|
|
}
|
|
|
|
/** GET /workspace/branding — the shape the shell boots from. */
|
|
export interface WorkspaceBranding extends BrandingRecord {
|
|
workspace: { id: string; name: string };
|
|
/** API-relative, scoped read path; run it through brandingAssetUrl(). */
|
|
logoUrl: string | null;
|
|
backgroundUrl: string | null;
|
|
}
|
|
|
|
/** POST /workspace/branding/logo — multipart upload result. */
|
|
export interface BrandingUploadResult {
|
|
key: string;
|
|
contentType: string;
|
|
sizeBytes: number;
|
|
/** The ready-to-send body for PUT /workspace/branding. */
|
|
patch: { logoAssetKey: string };
|
|
assetUrl: string;
|
|
}
|
|
|
|
export const MAX_TAGLINE_LENGTH = 160;
|
|
export const MAX_BRANDING_IMAGE_BYTES = 4 * 1024 * 1024;
|
|
export const BRANDING_IMAGE_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif"] as const;
|
|
export type BrandingImageType = (typeof BRANDING_IMAGE_TYPES)[number];
|
|
|
|
/** Same rule the API enforces on write: #RGB or #RRGGBB. */
|
|
const ACCENT_HEX = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
|
|
|
|
export function isHexAccent(value: string): boolean {
|
|
return ACCENT_HEX.test(value.trim());
|
|
}
|
|
|
|
/** The API returns API-relative asset paths; <img src> needs the absolute URL. */
|
|
export function brandingAssetUrl(path: string): string {
|
|
return path.startsWith("http") ? path : `${API_URL}${path.startsWith("/") ? path : `/${path}`}`;
|
|
}
|
|
|
|
export function fetchWorkspaceBranding(signal?: AbortSignal): Promise<WorkspaceBranding> {
|
|
return api.get<WorkspaceBranding>("/workspace/branding", undefined, signal);
|
|
}
|
|
|
|
/** PUT /workspace/branding — send every field you manage; null clears it. */
|
|
export function saveBranding(patch: BrandingPatch): Promise<BrandingRecord> {
|
|
return apiFetch<BrandingRecord>("/workspace/branding", { method: "PUT", body: patch });
|
|
}
|
|
|
|
export function uploadBrandingLogo(
|
|
file: File,
|
|
onProgress: (percent: number) => void = () => undefined,
|
|
): Promise<BrandingUploadResult> {
|
|
return apiUploadFile<BrandingUploadResult>("/workspace/branding/logo", file, onProgress);
|
|
}
|
|
|
|
export type BrandingSource = "loading" | "live" | "unavailable";
|
|
|
|
export interface BrandingContextValue {
|
|
workspaceId: string | null;
|
|
workspaceName: string | null;
|
|
logoAssetKey: string | null;
|
|
logoUrl: string | null;
|
|
tagline: string | null;
|
|
accent: string | null;
|
|
source: BrandingSource;
|
|
error: string | null;
|
|
/** Re-run the boot read (e.g. after Settings saves a patch). */
|
|
reload: () => void;
|
|
}
|
|
|
|
const BrandingContext = createContext<BrandingContextValue | null>(null);
|
|
|
|
const EMPTY: BrandingContextValue = {
|
|
workspaceId: null,
|
|
workspaceName: null,
|
|
logoAssetKey: null,
|
|
logoUrl: null,
|
|
tagline: null,
|
|
accent: null,
|
|
source: "loading",
|
|
error: null,
|
|
reload: () => undefined,
|
|
};
|
|
|
|
/**
|
|
* One branding read per session/workspace, shared by the shell and Settings.
|
|
* A failure never throws into the tree — the consumers fall back to the Hub
|
|
* defaults (stock mark, no tagline), exactly like the API's tolerant decode.
|
|
*/
|
|
export function BrandingProvider({ children }: { children: ReactNode }): React.ReactElement {
|
|
const { status, session } = useAuth();
|
|
const workspaceId = session?.workspace.id ?? null;
|
|
const [record, setRecord] = useState<WorkspaceBranding | null>(null);
|
|
const [source, setSource] = useState<BrandingSource>("loading");
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [nonce, setNonce] = useState(0);
|
|
|
|
useEffect(() => {
|
|
if (status === "loading") return;
|
|
if (status !== "authenticated" || workspaceId === null) {
|
|
setRecord(null);
|
|
setSource("unavailable");
|
|
return;
|
|
}
|
|
const controller = new AbortController();
|
|
fetchWorkspaceBranding(controller.signal)
|
|
.then((live) => {
|
|
setRecord(live);
|
|
setSource("live");
|
|
setError(null);
|
|
})
|
|
.catch((err: unknown) => {
|
|
if (controller.signal.aborted) return;
|
|
setError(err instanceof ApiUnreachableError ? null : errorMessage(err));
|
|
setRecord(null);
|
|
setSource("unavailable");
|
|
});
|
|
return () => controller.abort();
|
|
}, [status, workspaceId, nonce]);
|
|
|
|
const reload = useCallback(() => setNonce((n) => n + 1), []);
|
|
|
|
const value = useMemo<BrandingContextValue>(
|
|
() =>
|
|
record === null
|
|
? { ...EMPTY, source, error, reload }
|
|
: {
|
|
workspaceId: record.workspace.id,
|
|
workspaceName: record.workspace.name,
|
|
logoAssetKey: record.logoAssetKey,
|
|
logoUrl: record.logoUrl === null ? null : brandingAssetUrl(record.logoUrl),
|
|
tagline: record.tagline,
|
|
accent: record.accent,
|
|
source,
|
|
error,
|
|
reload,
|
|
},
|
|
[record, source, error, reload],
|
|
);
|
|
|
|
return createElement(BrandingContext.Provider, { value }, children);
|
|
}
|
|
|
|
export function useBranding(): BrandingContextValue {
|
|
const ctx = useContext(BrandingContext);
|
|
if (ctx === null) throw new Error("useBranding must be used inside <BrandingProvider>");
|
|
return ctx;
|
|
}
|