T-HUB/apps/web/src/lib/branding.ts
glass beadian d9e8cdf789 feat: produce worker deployment + web producer/collective/branding UI + mobile CSS (wave-2c)
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).
2026-10-14 20:40:49 +00:00

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;
}