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.
73 lines
3.8 KiB
TypeScript
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>;
|
|
}
|