T-HUB/apps/web/src/app/(main)/layout.tsx
glass beadian d58db63fe6 Add group cloud: files index api, web UI, archive sync bot
The Telegram-like group file contract (packages/core shared-files.ts) becomes
a working subsystem, built in three parallel tracks:

API (NestJS): migration 029 group_files (chat_media_archive metadata-only
index with partial replay-unique + RLS, group_file_shares with sha256 token
persistence, archive_sync_state/outbox durable-claim seam incl. a unique
claim_key index), group-files module (list/search with cursor pagination,
direct upload ≤20 MiB streamed+hashed to oCIS WebDAV, download with ACL +
retention rechecks, share links with expiry, retention modes incl.
legalHold) — RBAC group-membership authz + audit chain events.

Web: Files section per group — group picker, drag-drop upload (20 MiB cap),
search + MIME/origin filters, cursor pagination, download via authenticated
blob fetch, share dialog with expiry + shown-once token, admin retention
dialog; bilingual EN/ES (wiki i18n pattern); nav sidebar/palette/bottom-nav
wiring; MIME classes aligned to the DB CHECK (document/other).

Archive bot (apps/archive-bot): plain sync-loop Matrix bot mirroring
attachments from explicitly-mapped unencrypted archive rooms — raw /sync
with durable since-cursor, outbox-claimed ingestion (hash-verified stream,
no temp files), magic-byte MIME sniffing, safe-name normalization, WebDAV
upload, indexed + replied with link, redaction→unavailable; health endpoint;
Dockerfile. Tests 74/74, typecheck clean across api/web/bot.
2026-10-11 08:21:11 +00:00

73 lines
3.8 KiB
TypeScript

"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", files: "Files",
};
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>;
}