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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue