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
73
apps/web/src/app/(main)/layout.tsx
Normal file
73
apps/web/src/app/(main)/layout.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef, type ReactNode } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Sidebar } from "../../components/shell/sidebar";
|
||||
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 { AuthGate, SessionStatus } from "../../components/auth/auth-gate";
|
||||
import { useApiResource } from "../../lib/use-api";
|
||||
import type { DraftRecord } from "../../lib/api-types";
|
||||
import { useIsMobile } from "../../hooks/use-is-mobile";
|
||||
|
||||
const titles: Record<string, string> = {
|
||||
streams: "Home", search: "Search", inbox: "Inbox", approvals: "Approvals", more: "More", compose: "New post",
|
||||
calendar: "Calendar", video: "Video", analytics: "Analytics", connections: "Connections", team: "Team",
|
||||
audit: "Audit log", onboarding: "Welcome", settings: "Settings", archive: "Archive",
|
||||
};
|
||||
const rootTabs = new Set(["/streams", "/search", "/inbox", "/more"]);
|
||||
const scrollPositions = new Map<string, { main: number; nested: number[] }>();
|
||||
const identity = (drafts: DraftRecord[]): DraftRecord[] => drafts;
|
||||
const emptyDrafts: DraftRecord[] = [];
|
||||
|
||||
function scrollableChildren(main: HTMLElement): HTMLElement[] {
|
||||
return Array.from(main.querySelectorAll<HTMLElement>("*")).filter((element) =>
|
||||
element.scrollHeight > element.clientHeight && /auto|scroll/.test(getComputedStyle(element).overflowY));
|
||||
}
|
||||
|
||||
function Shell({ children }: { children: ReactNode }): React.ReactElement {
|
||||
const pathname = usePathname();
|
||||
const mobile = useIsMobile();
|
||||
const { can } = useAuth();
|
||||
const drafts = useApiResource<DraftRecord[], DraftRecord[]>("/publishing/drafts", identity, emptyDrafts, { state: "in_review", limit: 100 });
|
||||
// Badge comes from live approvals-in-review only — 0 while loading/sample/forbidden, never fixture counts.
|
||||
const approvals = can("approval.grant") && drafts.source === "live" ? drafts.data.length : 0;
|
||||
const mainRef = useRef<HTMLElement>(null);
|
||||
useEffect(() => {
|
||||
const main = mainRef.current;
|
||||
if (main === null || !mobile) return;
|
||||
const saved = scrollPositions.get(pathname);
|
||||
const restore = window.setTimeout(() => {
|
||||
main.scrollTop = saved?.main ?? 0;
|
||||
scrollableChildren(main).forEach((element, index) => { element.scrollTop = saved?.nested[index] ?? 0; });
|
||||
}, 0);
|
||||
return () => {
|
||||
window.clearTimeout(restore);
|
||||
scrollPositions.set(pathname, { main: main.scrollTop, nested: scrollableChildren(main).map((element) => element.scrollTop) });
|
||||
};
|
||||
}, [pathname, mobile]);
|
||||
const title = titles[pathname.split("/")[1] ?? ""] ?? "Tactical Media Broadcast Hub";
|
||||
return (
|
||||
<div className="flex h-dvh min-w-0 overflow-hidden lg:h-screen">
|
||||
<Sidebar />
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<div className="hidden h-11 shrink-0 items-center justify-between border-b border-border-subtle bg-surface/60 px-4 backdrop-blur lg:flex">
|
||||
<div className="text-2xs text-text-faint">Press <kbd className="rounded border border-border bg-surface-2 px-1 py-0.5 font-mono">⌘K</kbd> for commands</div>
|
||||
<div className="text-2xs text-text-faint"><SessionStatus /></div>
|
||||
</div>
|
||||
{mobile && <MobileTopBar title={title} back={!rootTabs.has(pathname)} />}
|
||||
<main ref={mainRef} data-mobile-main className="min-h-0 min-w-0 flex-1 overflow-x-hidden overflow-y-auto bg-bg lg:overflow-hidden">
|
||||
<AuthGate>{children}</AuthGate>
|
||||
</main>
|
||||
{mobile && <BottomNav badges={{ inbox: approvals }} />}
|
||||
</div>
|
||||
{!mobile && <CommandPalette />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MainLayout({ children }: { children: ReactNode }): React.ReactNode {
|
||||
return <AuthProvider><Shell>{children}</Shell></AuthProvider>;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue