Tactical Media Broadcast Hub — initial release
Self-hosted multi-platform broadcast newsroom for activist collectives: publishing pipeline with group approval and DST-proof scheduling, TweetDeck-style column wall with hostile-content-safe rendering, live control room with Auto Director, bridged chat governance with polls and quorum, distributed sandboxed transcode workers, encrypted content-addressed archive, monthly analytics. MIT licensed. See README.md and docs/.
This commit is contained in:
commit
aa5e262f3a
880 changed files with 111042 additions and 0 deletions
28
apps/web/src/components/brand/empty-state.tsx
Normal file
28
apps/web/src/components/brand/empty-state.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import type { ReactElement } from "react";
|
||||
import { CircleA } from "./logo";
|
||||
|
||||
/**
|
||||
* Branded empty state: the circle-A outline sitting low-contrast behind the
|
||||
* message, the way a screen-printed mark sits behind text on a black tee.
|
||||
*/
|
||||
export function EmptyState({
|
||||
title,
|
||||
hint,
|
||||
size = 64,
|
||||
className = "",
|
||||
}: {
|
||||
title: string;
|
||||
hint?: string;
|
||||
size?: number;
|
||||
className?: string;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<div className={`flex flex-col items-center justify-center gap-3 py-8 text-center ${className}`}>
|
||||
<CircleA size={size} className="text-accent/30" />
|
||||
<p className="text-sm font-medium text-text-dim">{title}</p>
|
||||
{hint !== undefined && (
|
||||
<p className="max-w-sm text-2xs leading-relaxed text-text-faint">{hint}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
90
apps/web/src/components/brand/index.tsx
Normal file
90
apps/web/src/components/brand/index.tsx
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import type { ReactElement } from "react";
|
||||
|
||||
interface LogoProps {
|
||||
size?: number;
|
||||
withText?: boolean;
|
||||
}
|
||||
|
||||
export function Logo({ size = 32, withText = false }: LogoProps): ReactElement {
|
||||
return (
|
||||
<span className="flex items-center gap-2">
|
||||
<svg width={size} height={size} viewBox="0 0 1200 1200" aria-label="Tactical Media Broadcast Hub">
|
||||
<g fill="none" stroke="var(--color-accent)" strokeWidth="72" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="600" cy="610" r="330" />
|
||||
<path d="M 365 915 L 600 250 L 835 915" strokeWidth="92" />
|
||||
<path d="M 250 610 L 950 610" strokeWidth="82" />
|
||||
</g>
|
||||
<g fill="var(--color-accent)">
|
||||
<polygon points="600.0,145.0 660.0,270.0 540.0,270.0" />
|
||||
<polygon points="125.0,610.0 250.0,550.0 250.0,670.0" />
|
||||
<polygon points="1075.0,610.0 950.0,670.0 950.0,550.0" />
|
||||
<polygon points="285.0,1050.0 331.0,919.2 415.8,1004.0" />
|
||||
<polygon points="915.0,1050.0 784.2,1004.0 869.0,919.2" />
|
||||
</g>
|
||||
</svg>
|
||||
{withText && (
|
||||
<span className="text-sm font-bold tracking-tight" style={{ color: "var(--color-accent)" }}>
|
||||
Tactical Media Broadcast Hub
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function Spinner({ size = 24 }: { size?: number }): ReactElement {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 1200 1200"
|
||||
className="animate-spin"
|
||||
style={{ animationDuration: "1.2s" }}
|
||||
aria-label="Loading"
|
||||
>
|
||||
<path d="M 600 280 A 330 330 0 0 1 930 610" fill="none" stroke="var(--color-accent)" strokeWidth="90" strokeLinecap="round" />
|
||||
<path d="M 930 610 A 330 330 0 0 1 600 940" fill="none" stroke="var(--color-accent)" strokeWidth="90" strokeLinecap="round" opacity="0.2" />
|
||||
<path d="M 600 940 A 330 330 0 0 1 270 610" fill="none" stroke="var(--color-accent)" strokeWidth="90" strokeLinecap="round" opacity="0.2" />
|
||||
<path d="M 270 610 A 330 330 0 0 1 600 280" fill="none" stroke="var(--color-accent)" strokeWidth="90" strokeLinecap="round" opacity="0.2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmptyState({ message, size = 120 }: { message: string; size?: number }): ReactElement {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-4 py-12">
|
||||
<svg width={size} height={size} viewBox="0 0 1200 1200" opacity="0.25">
|
||||
<g fill="none" stroke="var(--color-accent)" strokeWidth="80" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="600" cy="610" r="330" />
|
||||
<path d="M 365 915 L 600 250 L 835 915" strokeWidth="100" />
|
||||
<path d="M 250 610 L 950 610" strokeWidth="80" />
|
||||
</g>
|
||||
</svg>
|
||||
<p className="text-xs text-text-faint">{message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlatformIcon({ platform, size = 20 }: { platform: string; size?: number }): ReactElement {
|
||||
return (
|
||||
<img
|
||||
src={`/brand/icon-${platform}.svg`}
|
||||
alt={platform}
|
||||
width={size}
|
||||
height={size}
|
||||
className="rounded-full"
|
||||
loading="lazy"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function NavIcon({ name, size = 18 }: { name: string; size?: number }): ReactElement {
|
||||
return (
|
||||
<img
|
||||
src={`/brand/nav-${name}.svg`}
|
||||
alt={name}
|
||||
width={size}
|
||||
height={size}
|
||||
loading="lazy"
|
||||
/>
|
||||
);
|
||||
}
|
||||
62
apps/web/src/components/brand/loading.tsx
Normal file
62
apps/web/src/components/brand/loading.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import type { ReactElement } from "react";
|
||||
import { LogoMark } from "./logo";
|
||||
|
||||
/**
|
||||
* Branded loading state: the mark upright inside a spinning ring.
|
||||
*
|
||||
* The mark itself is radially asymmetric (the A's apex sits high in its
|
||||
* bounding box), so rotating the mark would wobble instead of spin. The ring
|
||||
* carries the rotation; the A stays plumb. `animation` rather than
|
||||
* Tailwind's animate-spin so globals.css's prefers-reduced-motion override
|
||||
* (0.01ms) still applies via the transition/animation shorthand.
|
||||
*/
|
||||
export function Loading({
|
||||
size = 28,
|
||||
label = "Loading…",
|
||||
className = "",
|
||||
}: {
|
||||
size?: number;
|
||||
label?: string;
|
||||
className?: string;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<span
|
||||
role="status"
|
||||
aria-label={label}
|
||||
className={`relative inline-flex items-center justify-center ${className}`}
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 100 100"
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 text-accent"
|
||||
style={{ animation: "anp-spin 0.9s linear infinite" }}
|
||||
>
|
||||
{/* Three-quarter arc: the gap becomes the motion cue. */}
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="44"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="6"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray="207 276"
|
||||
/>
|
||||
</svg>
|
||||
<LogoMark size={Math.round(size * 0.5)} className="text-accent" title="" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Full-panel variant for screens that are still fetching their first page. */
|
||||
export function LoadingPanel({ label }: { label?: string }): ReactElement {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3">
|
||||
<Loading size={32} label={label ?? "Loading…"} />
|
||||
{label !== undefined && <span className="text-2xs text-text-faint">{label}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
122
apps/web/src/components/brand/logo.tsx
Normal file
122
apps/web/src/components/brand/logo.tsx
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import type { ReactElement } from "react";
|
||||
|
||||
/**
|
||||
* The Tactical Media Broadcast Hub mark, inline.
|
||||
*
|
||||
* Source of truth: `public/brand/tactical-media-broadcast-hub.svg` — the arrows-from-circle
|
||||
* mark, tilted −17.5° counterclockwise so the top arrow points upper-left
|
||||
* (nothing static; everything points outward). The strokes use `currentColor`
|
||||
* so the mark always rides the workspace accent, which in the warm palette
|
||||
* lands within a hair of the brand red (#FF1610 ≈ oklch(0.63 0.24 29) vs the
|
||||
* token's oklch(0.62 0.22 30)). Geometry is copied 1:1 from the asset on its
|
||||
* 1700×1700 grid, minus the black plate and the screen-print distress paths
|
||||
* (those only read on pure black at full size).
|
||||
*/
|
||||
|
||||
export function LogoMark({
|
||||
size = 32,
|
||||
className = "text-accent",
|
||||
title = "Tactical Media Broadcast Hub",
|
||||
}: {
|
||||
size?: number;
|
||||
className?: string;
|
||||
title?: string;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 1700 1700"
|
||||
role="img"
|
||||
aria-label={title}
|
||||
className={className}
|
||||
>
|
||||
<g transform="rotate(-17.5 850 850)">
|
||||
<g fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="850" cy="850" r="350" strokeWidth="74" />
|
||||
<path d="M 850 405 L 464.77 1269.52" strokeWidth="88" />
|
||||
<path d="M 850 405 L 1235.23 1269.52" strokeWidth="88" />
|
||||
<path d="M 850 405 L 850 350" strokeWidth="88" />
|
||||
<path d="M 345 850 L 1355 850" strokeWidth="88" />
|
||||
</g>
|
||||
<g fill="currentColor">
|
||||
<polygon points="850,240 925,385 775,385" />
|
||||
<polygon points="235,850 380,775 380,925" />
|
||||
<polygon points="1465,850 1320,925 1320,775" />
|
||||
<polygon points="420,1370 410.51,1207.03 547.52,1268.08" />
|
||||
<polygon points="1280,1370 1152.48,1268.08 1289.49,1207.03" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The quiet version of the mark: just the circle-A outline, no arrowheads.
|
||||
* Used wherever the brand needs to whisper — empty states, placeholders.
|
||||
*/
|
||||
export function CircleA({
|
||||
size = 64,
|
||||
className = "text-accent",
|
||||
}: {
|
||||
size?: number;
|
||||
className?: string;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 1700 1700"
|
||||
aria-hidden="true"
|
||||
className={className}
|
||||
>
|
||||
<g transform="rotate(-17.5 850 850)">
|
||||
<g fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="850" cy="850" r="350" strokeWidth="74" />
|
||||
<path d="M 850 405 L 464.77 1269.52" strokeWidth="88" />
|
||||
<path d="M 850 405 L 1235.23 1269.52" strokeWidth="88" />
|
||||
<path d="M 850 405 L 850 350" strokeWidth="88" />
|
||||
<path d="M 345 850 L 1355 850" strokeWidth="88" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A single arrowhead lifted from the mark — the brand's bullet point.
|
||||
* Points the same way the right-hand barb of the circle-A does.
|
||||
*/
|
||||
export function ArrowGlyph({
|
||||
size = 12,
|
||||
className = "text-accent",
|
||||
}: {
|
||||
size?: number;
|
||||
className?: string;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 120 120" aria-hidden="true" className={className}>
|
||||
<polygon points="120,60 30,14 30,106" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark + wordmark. Inline below ~96px (sidebar, palette); stacked above it
|
||||
* (onboarding hero) so the wordmark never gets dwarfed by the mark.
|
||||
*/
|
||||
export function Logo({ size = 32, withText = true }: { size?: number; withText?: boolean }): ReactElement {
|
||||
if (!withText) return <LogoMark size={size} />;
|
||||
const stacked = size >= 96;
|
||||
return (
|
||||
<span className={`inline-flex ${stacked ? "flex-col items-center gap-3" : "items-center gap-2.5"}`}>
|
||||
<LogoMark size={size} />
|
||||
<span
|
||||
className={`font-semibold tracking-tight text-text ${stacked ? "text-2xl" : ""}`}
|
||||
style={stacked ? undefined : { fontSize: `${Math.max(13, Math.round(size * 0.45))}px` }}
|
||||
>
|
||||
Tactical Media Broadcast Hub
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue