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).
This commit is contained in:
parent
1183d69849
commit
d9e8cdf789
18 changed files with 2384 additions and 20 deletions
|
|
@ -7,6 +7,7 @@ import { CommandPalette } from "../../components/shell/command-palette";
|
|||
import { BottomNav } from "../../components/mobile/bottom-nav";
|
||||
import { MobileTopBar } from "../../components/mobile/top-bar";
|
||||
import { AuthProvider, useAuth } from "../../lib/auth-store";
|
||||
import { BrandingProvider } from "../../lib/branding";
|
||||
import { AuthGate, SessionStatus } from "../../components/auth/auth-gate";
|
||||
import { useApiResource } from "../../lib/use-api";
|
||||
import type { DraftRecord } from "../../lib/api-types";
|
||||
|
|
@ -69,5 +70,8 @@ function Shell({ children }: { children: ReactNode }): React.ReactElement {
|
|||
}
|
||||
|
||||
export default function MainLayout({ children }: { children: ReactNode }): React.ReactNode {
|
||||
return <AuthProvider><Shell>{children}</Shell></AuthProvider>;
|
||||
// BrandingProvider sits inside AuthProvider (it reads the session to know
|
||||
// which collective to brand) and outside the shell, so both the sidebar
|
||||
// and Settings share one boot read of /workspace/branding.
|
||||
return <AuthProvider><BrandingProvider><Shell>{children}</Shell></BrandingProvider></AuthProvider>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,28 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Avatar, Button } from "../../../components/ui/primitives";
|
||||
import { errorMessage } from "../../../lib/api-client";
|
||||
import { Avatar, Badge, Button, Card, Input } from "../../../components/ui/primitives";
|
||||
import { api, errorMessage } from "../../../lib/api-client";
|
||||
import { useAuth } from "../../../lib/auth-store";
|
||||
import type { AuthResult } from "../../../lib/api-types";
|
||||
import {
|
||||
BRANDING_IMAGE_TYPES,
|
||||
MAX_BRANDING_IMAGE_BYTES,
|
||||
MAX_TAGLINE_LENGTH,
|
||||
brandingAssetUrl,
|
||||
isHexAccent,
|
||||
saveBranding,
|
||||
uploadBrandingLogo,
|
||||
useBranding,
|
||||
type BrandingPatch,
|
||||
} from "../../../lib/branding";
|
||||
|
||||
/**
|
||||
* Deliberately small: this page states exactly what the API knows about the
|
||||
* current session and how to end it. There is no notifications/preferences
|
||||
* endpoint in the API, so none of that is faked here — no toggles that
|
||||
* pretend to persist.
|
||||
* Settings stays honest about what the API knows: the session block below is
|
||||
* read-only truth, and the two mutation surfaces the API actually offers sit
|
||||
* beside it — creating another collective (POST /auth/workspaces switches you
|
||||
* into it) and the collective's branding (PUT /workspace/branding, Admin).
|
||||
*/
|
||||
|
||||
function Field({ label, value, mono = false }: { label: string; value: string; mono?: boolean }): React.ReactElement {
|
||||
|
|
@ -22,6 +34,304 @@ function Field({ label, value, mono = false }: { label: string; value: string; m
|
|||
);
|
||||
}
|
||||
|
||||
function SectionHeading({ title, note }: { title: string; note: string }): React.ReactElement {
|
||||
return (
|
||||
<div className="border-b border-border-subtle p-4">
|
||||
<h2 className="text-sm font-semibold tracking-tight">{title}</h2>
|
||||
<p className="mt-1 text-2xs leading-relaxed text-text-faint">{note}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Create a collective and switch this device into it (Owner of the new one). */
|
||||
function CollectivesSection(): React.ReactElement {
|
||||
const { adoptToken, refresh } = useAuth();
|
||||
const [name, setName] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [created, setCreated] = useState<string | null>(null);
|
||||
|
||||
const ready = name.trim().length >= 2;
|
||||
|
||||
async function create(): Promise<void> {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setCreated(null);
|
||||
try {
|
||||
const result = await api.post<AuthResult>("/auth/workspaces", { workspaceName: name.trim() }, { redirectOn401: false });
|
||||
// The response is a session for the NEW collective: install its token
|
||||
// (the old one stays valid on the server for the old workspace), then
|
||||
// re-verify so /me, the shell and branding all follow the switch.
|
||||
adoptToken(result.token);
|
||||
await refresh();
|
||||
setCreated(result.me.workspace.name);
|
||||
setName("");
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="mb-6">
|
||||
<Card>
|
||||
<SectionHeading
|
||||
title="Collectives"
|
||||
note="Create another collective owned by you. The API returns a session scoped to it, so this device switches into the new collective immediately — you become its Owner, and your previous session stays valid on the server."
|
||||
/>
|
||||
<div className="flex flex-col gap-2 p-4 sm:flex-row">
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder="New collective name"
|
||||
aria-label="New collective name"
|
||||
maxLength={100}
|
||||
className="sm:flex-1"
|
||||
/>
|
||||
<Button variant="primary" disabled={busy || !ready} onClick={() => void create()} className="justify-center">
|
||||
{busy ? "Creating…" : "Create collective"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="px-4 pb-4">
|
||||
{error !== null && <p role="alert" className="text-xs text-danger">{error}</p>}
|
||||
{error === null && created !== null && (
|
||||
<p className="text-xs text-live">
|
||||
Created <span className="font-semibold">{created}</span> — you are now working inside it.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Branding: tagline, accent, logo. Admin-only in practice because the API
|
||||
* gates PUT /workspace/branding (and the logo upload) on workspace.members.manage.
|
||||
*/
|
||||
function BrandingSection(): React.ReactElement {
|
||||
const { can } = useAuth();
|
||||
const branding = useBranding();
|
||||
const allowed = can("workspace.members.manage");
|
||||
|
||||
const [tagline, setTagline] = useState("");
|
||||
const [accent, setAccent] = useState("");
|
||||
const [logoKey, setLogoKey] = useState<string | null>(null);
|
||||
const [logoPreview, setLogoPreview] = useState<string | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
// Seed the form once per collective from the boot read; after a save the
|
||||
// local state already matches the server, so no reseed is needed.
|
||||
const seeded = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (branding.source !== "live" || branding.workspaceId === null || seeded.current === branding.workspaceId) return;
|
||||
seeded.current = branding.workspaceId;
|
||||
setTagline(branding.tagline ?? "");
|
||||
setAccent(branding.accent ?? "");
|
||||
setLogoKey(branding.logoAssetKey);
|
||||
setLogoPreview(branding.logoUrl);
|
||||
setError(null);
|
||||
setSaved(false);
|
||||
}, [branding]);
|
||||
|
||||
if (!allowed) {
|
||||
return (
|
||||
<section className="mb-6">
|
||||
<Card>
|
||||
<SectionHeading
|
||||
title="Branding"
|
||||
note="The collective's logo, tagline and accent. Only Owners and Admins can change branding."
|
||||
/>
|
||||
<div className="flex items-start gap-3 p-4">
|
||||
<Badge tone="neutral">read-only</Badge>
|
||||
<p className="text-xs text-text-faint">
|
||||
Your role ({can("workspace.view") ? "member" : "guest"}) cannot edit branding — the API requires the
|
||||
<code className="mx-1 font-mono">workspace.members.manage</code> permission.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const trimmedTagline = tagline.trim();
|
||||
const trimmedAccent = accent.trim();
|
||||
const taglineProblem =
|
||||
trimmedTagline.length > MAX_TAGLINE_LENGTH ? `At most ${MAX_TAGLINE_LENGTH} characters.` : null;
|
||||
const accentProblem =
|
||||
trimmedAccent === "" || isHexAccent(trimmedAccent) ? null : "Use a hex color like #2E7D32 or #E33.";
|
||||
const ready = taglineProblem === null && accentProblem === null && !busy && !uploading;
|
||||
const swatch = isHexAccent(trimmedAccent) ? trimmedAccent : null;
|
||||
|
||||
async function upload(file: File): Promise<void> {
|
||||
setError(null);
|
||||
setSaved(false);
|
||||
if (!(BRANDING_IMAGE_TYPES as readonly string[]).includes(file.type)) {
|
||||
setError(`Logo must be one of ${BRANDING_IMAGE_TYPES.join(", ")}.`);
|
||||
return;
|
||||
}
|
||||
if (file.size > MAX_BRANDING_IMAGE_BYTES) {
|
||||
setError(`Logo must be under ${Math.round(MAX_BRANDING_IMAGE_BYTES / 1024 / 1024)} MB.`);
|
||||
return;
|
||||
}
|
||||
setUploading(true);
|
||||
setProgress(0);
|
||||
try {
|
||||
// Upload stores the image and hands back its key; it goes live as the
|
||||
// collective's mark only when Save PUTs it below.
|
||||
const result = await uploadBrandingLogo(file, setProgress);
|
||||
setLogoKey(result.key);
|
||||
setLogoPreview(brandingAssetUrl(result.assetUrl));
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function save(): Promise<void> {
|
||||
setError(null);
|
||||
setSaved(false);
|
||||
setBusy(true);
|
||||
try {
|
||||
const patch: BrandingPatch = {
|
||||
// Empty means "back to the Hub default" — the API takes null to clear.
|
||||
tagline: trimmedTagline === "" ? null : trimmedTagline,
|
||||
accent: trimmedAccent === "" ? null : trimmedAccent,
|
||||
logoAssetKey: logoKey,
|
||||
};
|
||||
await saveBranding(patch);
|
||||
branding.reload();
|
||||
setSaved(true);
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="mb-6">
|
||||
<Card>
|
||||
<SectionHeading
|
||||
title="Branding"
|
||||
note={`How this collective appears in the shell: its mark, a one-line tagline and an accent hex. Leave a field empty to return to the Hub default. Tagline up to ${MAX_TAGLINE_LENGTH} characters.`}
|
||||
/>
|
||||
<div className="space-y-4 p-4">
|
||||
<div>
|
||||
<label htmlFor="branding-tagline" className="mb-1 block text-xs font-medium text-text-dim">Tagline</label>
|
||||
<Input
|
||||
id="branding-tagline"
|
||||
value={tagline}
|
||||
onChange={(event) => { setTagline(event.target.value); setSaved(false); }}
|
||||
placeholder="Toma los medios"
|
||||
maxLength={MAX_TAGLINE_LENGTH + 20}
|
||||
/>
|
||||
<div className="mt-1 flex items-center justify-between gap-2">
|
||||
<span className="text-2xs text-text-faint">Shown under the collective name in the sidebar.</span>
|
||||
<span className={`text-2xs ${taglineProblem ? "text-danger" : "text-text-faint"}`}>
|
||||
{trimmedTagline.length}/{MAX_TAGLINE_LENGTH}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="branding-accent" className="mb-1 block text-xs font-medium text-text-dim">Accent color</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="branding-accent"
|
||||
value={accent}
|
||||
onChange={(event) => { setAccent(event.target.value); setSaved(false); }}
|
||||
placeholder="#FF1610"
|
||||
className="font-mono"
|
||||
aria-invalid={accentProblem !== null}
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
title="Accent preview"
|
||||
className="h-[30px] w-[30px] shrink-0 rounded-md border border-border"
|
||||
style={{ background: swatch ?? "transparent" }}
|
||||
/>
|
||||
<input
|
||||
type="color"
|
||||
aria-label="Pick accent color"
|
||||
value={swatch !== null && swatch.length === 7 ? swatch : "#ff1610"}
|
||||
onChange={(event) => { setAccent(event.target.value.toUpperCase()); setSaved(false); }}
|
||||
className="h-[30px] w-[34px] shrink-0 cursor-pointer rounded-md border border-border bg-surface-2 p-0.5"
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 text-2xs text-text-faint">Hex only (#RGB or #RRGGBB). Empty uses the Hub red.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="mb-1 block text-xs font-medium text-text-dim">Logo</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-md border border-border-subtle bg-surface-2">
|
||||
{logoPreview !== null ? (
|
||||
<img src={logoPreview} alt="Collective logo preview" className="h-full w-full object-contain" />
|
||||
) : (
|
||||
<span className="text-2xs text-text-faint">none</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<input
|
||||
type="file"
|
||||
accept={BRANDING_IMAGE_TYPES.join(",")}
|
||||
aria-label="Upload collective logo"
|
||||
disabled={uploading}
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (file !== undefined) void upload(file);
|
||||
}}
|
||||
className="w-full cursor-pointer text-2xs text-text-dim file:mr-2 file:cursor-pointer file:rounded-md file:border-0 file:bg-accent file:px-2 file:py-1 file:text-2xs file:font-medium file:text-white hover:file:bg-accent-hover"
|
||||
/>
|
||||
<div className="flex items-center gap-2 text-2xs text-text-faint">
|
||||
{uploading ? (
|
||||
<span>Uploading… {progress}%</span>
|
||||
) : logoKey !== null ? (
|
||||
<>
|
||||
<span className="truncate font-mono" title={logoKey}>{logoKey}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => { setLogoKey(null); setLogoPreview(null); setSaved(false); }}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<span>PNG, JPEG, WebP or GIF up to {Math.round(MAX_BRANDING_IMAGE_BYTES / 1024 / 1024)} MB.</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{logoPreview !== null && saved === false && (
|
||||
<p className="mt-1 text-2xs text-warn">Saved only when you press Save branding.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error !== null && <p role="alert" className="text-xs text-danger">{error}</p>}
|
||||
{error === null && saved && <p className="text-xs text-live">Branding saved.</p>}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="primary" disabled={!ready} onClick={() => void save()}>
|
||||
{busy ? "Saving…" : "Save branding"}
|
||||
</Button>
|
||||
{(taglineProblem !== null || accentProblem !== null) && (
|
||||
<span className="text-2xs text-danger">{taglineProblem ?? accentProblem}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SettingsPage(): React.ReactElement {
|
||||
const router = useRouter();
|
||||
const { session, status, offline, logout } = useAuth();
|
||||
|
|
@ -50,10 +360,13 @@ export default function SettingsPage(): React.ReactElement {
|
|||
<div className="mx-auto max-w-2xl">
|
||||
<h1 className="mb-1 text-lg font-semibold">Settings</h1>
|
||||
<p className="mb-5 text-xs text-text-faint">
|
||||
What the API knows about this session. Workspace-level configuration lives in Team and Connections; there is no
|
||||
per-user preference store in the API, so none is shown here.
|
||||
Your session, the collectives you can spin up, and this collective's branding. There is still no
|
||||
per-user preference store in the API, so nothing here pretends to save one.
|
||||
</p>
|
||||
|
||||
<CollectivesSection />
|
||||
<BrandingSection />
|
||||
|
||||
<section className="mb-6 rounded-lg border border-border-subtle bg-surface">
|
||||
<div className="flex items-center gap-4 border-b border-border-subtle p-4">
|
||||
<Avatar handle={session?.user.displayName || session?.user.username || "AP"} />
|
||||
|
|
|
|||
|
|
@ -74,6 +74,94 @@ html, body { max-width: 100%; }
|
|||
[data-mobile-main] .mobile-stack-table td > * { max-width: 100%; }
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Phone tier (≤640px) — native feel, touch targets, safe areas, overflow.
|
||||
Scoped globally (body-wide) rather than to [data-mobile-main] because the
|
||||
auth screens (/login, /signup, /join, /submit, /oidc) and the body-portal
|
||||
sheets render outside the main shell and were getting none of this.
|
||||
These rules are unlayered, so they outrank Tailwind's `@layer utilities`
|
||||
without needing !important.
|
||||
--------------------------------------------------------------------------- */
|
||||
@media (max-width: 640px) {
|
||||
/* -- Native feel: no tap highlight flash, no double-tap-zoom delay on
|
||||
controls (pinch zoom stays available — `manipulation` only disables
|
||||
the double-tap gesture), no iOS landscape text inflation, no document
|
||||
rubber-band behind the app shell. -- */
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
text-size-adjust: 100%;
|
||||
overscroll-behavior: none;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
body {
|
||||
max-width: 100%;
|
||||
overflow-x: hidden;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
body :where(button, a[href], [role="tab"], [role="button"], [role="switch"]) {
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
/* -- ≥44px touch targets for buttons / icon-buttons (glyph buttons here are
|
||||
plain <button>, bottom-nav tabs are <a>, segmented control is role=tab,
|
||||
sheet drag handle is role=button). min-width is limited to controls that
|
||||
are already box-shaped so inline text links keep their flow shape. -- */
|
||||
body :where(button, a[href], [role="tab"], [role="button"], [role="switch"]) {
|
||||
min-height: 44px;
|
||||
}
|
||||
body :where(nav button, nav a[href], [role="tab"]) {
|
||||
min-width: 44px;
|
||||
}
|
||||
|
||||
/* -- 16px minimum on text-entry controls: iOS Safari auto-zooms the viewport
|
||||
on focus whenever the field's computed font-size is under 16px, which
|
||||
reads as janky/non-native. Non-text inputs excluded (their size is
|
||||
visual, not textual). -- */
|
||||
body :where(
|
||||
input:not([type="checkbox"]):not([type="radio"]):not([type="range"]):not([type="file"]):not([type="button"]):not([type="submit"]):not([type="reset"]),
|
||||
select,
|
||||
textarea
|
||||
) {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* -- Icon sizing. Icons size via width/height attributes from React props,
|
||||
not a CSS variable or utility class — but HTML sizing attributes are
|
||||
presentational hints, so author CSS still wins without touching TSX.
|
||||
Bottom-nav glyph: 24px → 22px (target band 20–22px; top of band keeps
|
||||
it clearest at phone density). Inline SVG icons: ≥16px floor so the
|
||||
14px spinners and 11px arrow glyphs stop rendering sub-legibly. -- */
|
||||
nav[aria-label="Primary navigation"] img {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
body :where(svg) {
|
||||
min-width: 16px;
|
||||
min-height: 16px;
|
||||
}
|
||||
|
||||
/* -- Safe areas. Bottom is already covered app-wide via --mobile-safe-bottom
|
||||
(bottom nav, sheets, compose's fixed action bar). This adds the other
|
||||
three edges on the shell column that holds top bar + main + bottom nav
|
||||
— the notch/home-indicator sides that only bite in landscape.
|
||||
max() keeps the value at 0 on devices without insets, and degrades to
|
||||
no padding at all on browsers without :has(). -- */
|
||||
:has(> main[data-mobile-main]) {
|
||||
padding-top: max(0px, env(safe-area-inset-top, 0px));
|
||||
padding-left: max(0px, env(safe-area-inset-left, 0px));
|
||||
padding-right: max(0px, env(safe-area-inset-right, 0px));
|
||||
}
|
||||
|
||||
/* -- Horizontal overflow guards: belt-and-braces on the scroller plus a
|
||||
cap on replaced media, the classic cause of stray phone scroll. -- */
|
||||
main[data-mobile-main] {
|
||||
max-width: 100%;
|
||||
}
|
||||
body :where(img, video, canvas, iframe, svg) {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--color-accent-dim);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,10 +3,12 @@
|
|||
import type { ReactNode } from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { LogoMark } from "../brand/logo";
|
||||
import { useBranding } from "../../lib/branding";
|
||||
|
||||
export function MobileTopBar({ title, actions, back = false }: { title: ReactNode; actions?: ReactNode; back?: boolean }): React.ReactElement {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const branding = useBranding();
|
||||
return (
|
||||
<header className="flex h-[60px] shrink-0 items-center gap-3 border-b border-border-subtle bg-surface px-4 lg:hidden">
|
||||
{back ? (
|
||||
|
|
@ -14,6 +16,8 @@ export function MobileTopBar({ title, actions, back = false }: { title: ReactNod
|
|||
if (window.history.length > 1) router.back();
|
||||
else router.push(pathname.startsWith("/inbox/") ? "/inbox" : "/more");
|
||||
}} className="-ml-2 flex h-11 w-11 shrink-0 items-center justify-center rounded-full text-xl">←</button>
|
||||
) : branding.logoUrl !== null ? (
|
||||
<img src={branding.logoUrl} alt="Collective logo" width={32} height={32} className="h-8 w-8 shrink-0 rounded object-contain" />
|
||||
) : <LogoMark size={32} />}
|
||||
<div className="min-w-0 flex-1 truncate text-lg font-bold">{title}</div>
|
||||
{actions}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useAuth } from "../../lib/auth-store";
|
||||
import { useBranding } from "../../lib/branding";
|
||||
import { useApiResource } from "../../lib/use-api";
|
||||
import type { DraftRecord } from "../../lib/api-types";
|
||||
import { LogoMark } from "../brand/logo";
|
||||
|
|
@ -22,7 +23,7 @@ const NAV: { href: string; label: string; icon: string; external?: boolean }[] =
|
|||
{ href: "/analytics", label: "Analytics", icon: "analytics" },
|
||||
{ href: "/connections", label: "Channels", icon: "connections" },
|
||||
{ href: "/team", label: "Team", icon: "team" },
|
||||
{ href: "/audit", label: "Audit", icon: "audit" },
|
||||
{ href: "/audit", label: "Audit log", icon: "audit" },
|
||||
{ href: "/onboarding", label: "Onboarding", icon: "home" },
|
||||
{ href: "/settings", label: "Settings", icon: "settings" },
|
||||
];
|
||||
|
|
@ -33,19 +34,41 @@ const emptyDrafts: DraftRecord[] = [];
|
|||
export function Sidebar(): React.ReactElement {
|
||||
const pathname = usePathname();
|
||||
const { session, can } = useAuth();
|
||||
const branding = useBranding();
|
||||
const drafts = useApiResource<DraftRecord[], DraftRecord[]>("/publishing/drafts", identity, emptyDrafts, { state: "in_review", limit: 100 });
|
||||
// Real in-review count from the API; hidden while loading/offline and for non-approvers.
|
||||
const approvals = can("approval.grant") && drafts.source === "live" ? drafts.data.length : 0;
|
||||
const workspaceName = session?.workspace.name ?? "Studio";
|
||||
// Branding is per collective; the session's workspace is the source of truth
|
||||
// for the name, branding only decorates it (and may be loading/unavailable).
|
||||
const workspaceName = session?.workspace.name ?? branding.workspaceName ?? "Studio";
|
||||
const showTagline = branding.source === "live" && branding.tagline !== null;
|
||||
return (
|
||||
<aside className="hidden h-screen w-52 shrink-0 flex-col border-r border-border-subtle bg-surface lg:flex">
|
||||
<div className="flex items-center gap-2.5 border-b border-border-subtle px-4 py-4">
|
||||
<LogoMark size={30} title="Tactical Media Broadcast Hub" />
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-semibold leading-tight tracking-tight">Tactical Media Broadcast Hub</div>
|
||||
<div className="truncate text-2xs text-text-faint" title={workspaceName}>
|
||||
{workspaceName} ▾
|
||||
<div className="flex items-start gap-2.5 border-b border-border-subtle px-4 py-4">
|
||||
{branding.logoUrl !== null ? (
|
||||
<img
|
||||
src={branding.logoUrl}
|
||||
alt={`${workspaceName} logo`}
|
||||
width={30}
|
||||
height={30}
|
||||
className="mt-0.5 h-[30px] w-[30px] shrink-0 rounded object-contain"
|
||||
/>
|
||||
) : (
|
||||
<LogoMark size={30} title="Tactical Media Broadcast Hub" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-xs font-semibold leading-tight tracking-tight" title={workspaceName}>
|
||||
{workspaceName}
|
||||
</div>
|
||||
{showTagline ? (
|
||||
<div className="mt-0.5 truncate text-2xs leading-snug text-text-faint" title={branding.tagline ?? undefined}>
|
||||
{branding.tagline}
|
||||
</div>
|
||||
) : (
|
||||
<div className="truncate text-2xs text-text-faint" title={workspaceName}>
|
||||
Tactical Media Broadcast Hub
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<nav className="flex-1 overflow-y-auto scrollbar-thin p-2">
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ export class ApiUnreachableError extends Error {
|
|||
type QueryValue = string | number | undefined | null;
|
||||
|
||||
export interface ApiRequestOptions {
|
||||
method?: "GET" | "POST" | "PATCH" | "DELETE";
|
||||
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
body?: unknown;
|
||||
query?: Record<string, QueryValue>;
|
||||
signal?: AbortSignal;
|
||||
|
|
|
|||
174
apps/web/src/lib/branding.ts
Normal file
174
apps/web/src/lib/branding.ts
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
"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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue