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.
This commit is contained in:
glass beadian 2026-10-11 08:21:11 +00:00
parent eb1b60f67d
commit d58db63fe6
58 changed files with 5543 additions and 4 deletions

View file

@ -0,0 +1,13 @@
"use client";
import type { ReactNode } from "react";
import { FilesLangProvider } from "../../../components/files/i18n";
/**
* Files shell. Like the wiki, the language lives in localStorage per browser
* (lang-context explains why it is not an account setting) and is provided here
* so every Files screen and sub-component reads it through one context.
*/
export default function FilesLayout({ children }: { children: ReactNode }): React.ReactElement {
return <FilesLangProvider>{children}</FilesLangProvider>;
}

View file

@ -0,0 +1,383 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { Button, Input } from "../../../components/ui/primitives";
import { OfflineBadge } from "../../../components/ui/offline-badge";
import { EmptyState } from "../../../components/brand/empty-state";
import { Loading } from "../../../components/brand/loading";
import { api, ApiUnreachableError, errorMessage } from "../../../lib/api-client";
import { useAuth } from "../../../lib/auth-store";
import { useApiResource } from "../../../lib/use-api";
import { FileRow } from "../../../components/files/file-row";
import { UploadDropzone } from "../../../components/files/upload-dropzone";
import { ShareDialog } from "../../../components/files/share-dialog";
import { RetentionDialog } from "../../../components/files/retention-dialog";
import { downloadGroupFile } from "../../../components/files/files-client";
import { useFilesLang } from "../../../components/files/i18n";
import { MIME_CLASS_LABEL, ORIGIN_LABEL } from "../../../components/files/labels";
import type {
FileOrigin,
GroupFileItem,
GroupFilePage,
GroupSummary,
MimeClass,
RetentionInput,
} from "../../../components/files/types";
/**
* Group file library — the per-group "group cloud". Live mode pages through
* GET /files/groups/:groupId/items by cursor (video library's load-more
* pattern) with a debounced server-side `q` and mimeClass/origin filters.
* Offline the screen renders sample data read-only per useApiResource rules.
*/
const PAGE_LIMIT = 50;
const SAMPLE_GROUPS: GroupSummary[] = [{ id: "sample-group", name: "Collective media" }];
const SAMPLE_FILES: GroupFileItem[] = [
{
id: "sample-1", groupId: "sample-group", origin: "matrix", senderMxid: "@ada:collective.example",
originalName: "press-kit.zip", safeName: "press-kit.zip", mime: "application/zip", mimeClass: "other",
byteSize: 4_812_441, sha256: "a".repeat(64), createdAt: "2026-10-08T09:12:00.000Z", available: true,
retentionMode: "keep", retentionUntil: null,
},
{
id: "sample-2", groupId: "sample-group", origin: "upload", senderMxid: "@you:collective.example",
originalName: "launch-banner.png", safeName: "launch-banner.png", mime: "image/png", mimeClass: "image",
byteSize: 348_112, sha256: "b".repeat(64), createdAt: "2026-10-08T11:40:00.000Z", available: true,
retentionMode: "until", retentionUntil: "2026-11-30T00:00:00.000Z",
},
{
id: "sample-3", groupId: "sample-group", origin: "bridge", senderMxid: "@bridge:collective.example",
originalName: "statement-final.pdf", safeName: "statement-final.pdf", mime: "application/pdf", mimeClass: "document",
byteSize: 1_204_998, sha256: "c".repeat(64), createdAt: "2026-10-07T18:05:00.000Z", available: true,
retentionMode: "legalHold", retentionUntil: null,
},
];
const identityGroups = (groups: GroupSummary[]): GroupSummary[] => groups;
export default function FilesPage(): React.ReactElement {
const { t, lang, setLang } = useFilesLang();
const { status: authStatus, can, session } = useAuth();
const groups = useApiResource<GroupSummary[], GroupSummary[]>("/groups/mine", identityGroups, SAMPLE_GROUPS);
const [pickedGroup, setPickedGroup] = useState<string | null>(null);
const [items, setItems] = useState<GroupFileItem[]>([]);
const [source, setSource] = useState<"loading" | "live" | "sample">("loading");
const [error, setError] = useState<string | null>(null);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [nonce, setNonce] = useState(0);
const [qInput, setQInput] = useState("");
const [q, setQ] = useState("");
const [mimeClass, setMimeClass] = useState<"" | MimeClass>("");
const [origin, setOrigin] = useState<"" | FileOrigin>("");
const [actionError, setActionError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [busyId, setBusyId] = useState<string | null>(null);
const [shareFor, setShareFor] = useState<GroupFileItem | null>(null);
const [retentionFor, setRetentionFor] = useState<GroupFileItem | null>(null);
const [shares, setShares] = useState<Record<string, { shareId: string | null; tokenUrl: string }>>({});
const groupId = pickedGroup ?? groups.data[0]?.id ?? "";
const live = source === "live";
const isAdmin = can("workspace.members.manage") || session?.role === "Admin" || session?.role === "Owner";
// Debounce the search box; only the settled value drives the server query.
useEffect(() => {
const timer = window.setTimeout(() => setQ(qInput.trim()), 300);
return () => window.clearTimeout(timer);
}, [qInput]);
useEffect(() => {
if (authStatus === "loading") return;
if (authStatus !== "authenticated") {
setItems(SAMPLE_FILES);
setNextCursor(null);
setSource("sample");
setError(null);
return;
}
if (groupId === "") {
setItems([]);
setNextCursor(null);
setSource("live");
return;
}
const controller = new AbortController();
setLoading(true);
api
.get<GroupFilePage>(`/files/groups/${encodeURIComponent(groupId)}/items`, { q, mimeClass, origin, limit: PAGE_LIMIT }, controller.signal)
.then((page) => {
setItems(page.items);
setNextCursor(page.nextCursor);
setSource("live");
setError(null);
})
.catch((err: unknown) => {
if (controller.signal.aborted) return;
if (err instanceof ApiUnreachableError) {
setItems(SAMPLE_FILES);
setNextCursor(null);
setSource("sample");
return;
}
setSource("live");
setError(errorMessage(err));
})
.finally(() => {
if (!controller.signal.aborted) setLoading(false);
});
return () => controller.abort();
}, [authStatus, groupId, q, mimeClass, origin, nonce]);
async function loadMore(): Promise<void> {
if (nextCursor === null || groupId === "") return;
setLoadingMore(true);
try {
const page = await api.get<GroupFilePage>(`/files/groups/${encodeURIComponent(groupId)}/items`, {
q, mimeClass, origin, limit: PAGE_LIMIT, cursor: nextCursor,
});
setItems((current) => {
const seen = new Set(current.map((item) => item.id));
return [...current, ...page.items.filter((item) => !seen.has(item.id))];
});
setNextCursor(page.nextCursor);
} catch (err) {
setActionError(errorMessage(err));
} finally {
setLoadingMore(false);
}
}
const handleUploaded = useCallback((item: GroupFileItem): void => {
setItems((current) => [item, ...current.filter((existing) => existing.id !== item.id)]);
setNotice(t({ en: "File uploaded.", es: "Archivo subido." }));
}, [t]);
async function handleDownload(item: GroupFileItem): Promise<void> {
setActionError(null);
try {
await downloadGroupFile(item.id, item.safeName);
} catch (err) {
setActionError(errorMessage(err));
}
}
async function revokeShare(item: GroupFileItem): Promise<void> {
const entry = shares[item.id];
if (entry === undefined || entry.shareId === null || entry.shareId === "") {
setActionError(t({ en: "No share id to revoke — reopen the share dialog.", es: "No hay id de enlace que revocar — vuelve a abrir el cuadro de compartir." }));
return;
}
const previous = shares;
setBusyId(item.id);
setActionError(null);
// Optimistic: drop the link immediately, restore if the API rejects.
setShares((current) => {
const next = { ...current };
delete next[item.id];
return next;
});
try {
await api.del(`/files/shares/${encodeURIComponent(entry.shareId)}`);
setNotice(t({ en: "Share link revoked.", es: "Enlace para compartir revocado." }));
} catch (err) {
setShares(previous);
setActionError(errorMessage(err));
} finally {
setBusyId(null);
}
}
function handleRetentionSaved(itemId: string, input: RetentionInput): void {
setItems((current) =>
current.map((item) =>
item.id === itemId ? { ...item, retentionMode: input.mode, retentionUntil: input.until ?? null } : item,
),
);
setNotice(t({ en: "Retention updated.", es: "Retención actualizada." }));
}
const groupChosen = groupId !== "";
const showList = source !== "loading";
return (
<div className="h-full overflow-y-auto p-4 pb-24 scrollbar-thin lg:p-6">
<div className="mb-5 flex flex-wrap items-center gap-3">
<h1 className="text-lg font-semibold">{t({ en: "Files", es: "Archivos" })}</h1>
<OfflineBadge source={source} />
{groups.data.length > 0 && (
<label className="flex items-center gap-2 text-xs text-text-dim">
<span className="sr-only">{t({ en: "Group", es: "Grupo" })}</span>
<select
aria-label={t({ en: "Group", es: "Grupo" })}
value={groupId}
onChange={(event) => setPickedGroup(event.target.value)}
className="rounded-md border border-border bg-surface-2 px-2 py-1 text-xs"
>
{groups.data.map((group) => (
<option key={group.id} value={group.id}>{group.name}</option>
))}
</select>
</label>
)}
<div className="ml-auto flex shrink-0 items-center rounded-md border border-border bg-surface-2 p-0.5" role="group" aria-label="Language">
{(["en", "es"] as const).map((code) => (
<button
key={code}
type="button"
onClick={() => setLang(code)}
aria-pressed={lang === code}
className={`min-h-8 rounded px-2.5 text-2xs font-bold uppercase transition-colors duration-150 ${
lang === code ? "bg-accent text-white" : "text-text-dim hover:text-text"
}`}
>
{code}
</button>
))}
</div>
</div>
<p className="mb-4 text-sm text-text-dim">
{t({
en: "Files shared with this group, from Matrix rooms, bridges, direct uploads and pad exports. Download and share links stream the verified bytes back through the hub — nothing is stored on a third party.",
es: "Archivos compartidos con este grupo, desde salas Matrix, puentes, subidas directas y exportaciones de pad. La descarga y los enlaces de compartir sirven los bytes verificados a través del hub — nada queda en terceros.",
})}
</p>
{groups.error !== null && <p className="mb-3 text-sm text-danger">{groups.error}</p>}
{error !== null && <p className="mb-3 text-sm text-danger">{error}</p>}
{actionError !== null && <p role="alert" className="mb-3 text-sm text-danger">{actionError}</p>}
{notice !== null && <p role="status" className="mb-3 text-sm text-live">{notice}</p>}
<UploadDropzone groupId={groupId} live={live} onUploaded={handleUploaded} />
<div className="mb-4 flex flex-wrap items-center gap-2">
<Input
type="search"
value={qInput}
onChange={(event) => setQInput(event.target.value)}
placeholder={t({ en: "Search files by name…", es: "Buscar archivos por nombre…" })}
aria-label={t({ en: "Search files", es: "Buscar archivos" })}
className="max-w-xs"
/>
<select
aria-label={t({ en: "MIME class", es: "Clase MIME" })}
value={mimeClass}
onChange={(event) => setMimeClass(event.target.value as "" | MimeClass)}
className="rounded-md border border-border bg-surface-2 px-2 py-1.5 text-xs"
>
<option value="">{t({ en: "All types", es: "Todos los tipos" })}</option>
{(Object.keys(MIME_CLASS_LABEL) as MimeClass[]).map((value) => (
<option key={value} value={value}>{t(MIME_CLASS_LABEL[value])}</option>
))}
</select>
<select
aria-label={t({ en: "Origin", es: "Origen" })}
value={origin}
onChange={(event) => setOrigin(event.target.value as "" | FileOrigin)}
className="rounded-md border border-border bg-surface-2 px-2 py-1.5 text-xs"
>
<option value="">{t({ en: "All origins", es: "Todos los orígenes" })}</option>
{(Object.keys(ORIGIN_LABEL) as FileOrigin[]).map((value) => (
<option key={value} value={value}>{t(ORIGIN_LABEL[value])}</option>
))}
</select>
{live && (
<Button size="sm" variant="ghost" onClick={() => setNonce((n) => n + 1)} disabled={loading}>
{t({ en: "Refresh", es: "Actualizar" })}
</Button>
)}
</div>
{groups.data.length === 0 && source !== "sample" && (
<EmptyState
title={t({ en: "No groups yet", es: "Aún no hay grupos" })}
hint={t({ en: "Files live inside a group. Join or create one, then come back.", es: "Los archivos viven dentro de un grupo. Únete o crea uno y vuelve." })}
/>
)}
{groups.data.length > 0 && !groupChosen && source !== "sample" && (
<EmptyState
title={t({ en: "Choose a group", es: "Elige un grupo" })}
hint={t({ en: "Pick a group above to see its shared files.", es: "Elige un grupo arriba para ver sus archivos compartidos." })}
/>
)}
{source === "loading" && (
<div className="flex items-center gap-2 text-xs text-text-faint">
<Loading size={16} label={t({ en: "Loading files", es: "Cargando archivos" })} />
{t({ en: "Loading files…", es: "Cargando archivos…" })}
</div>
)}
{showList && loading && items.length > 0 && (
<div className="mb-3 flex items-center gap-2 text-2xs text-text-faint">
<Loading size={12} label={t({ en: "Refreshing", es: "Actualizando" })} />
{t({ en: "Refreshing…", es: "Actualizando…" })}
</div>
)}
{showList && !loading && (groupChosen || source === "sample") && items.length === 0 && (
<EmptyState
title={t({ en: "No files here yet", es: "Aún no hay archivos aquí" })}
hint={
q !== "" || mimeClass !== "" || origin !== ""
? t({ en: "No file matches these filters. Clear the search or filters.", es: "Ningún archivo coincide con estos filtros. Limpia la búsqueda o los filtros." })
: t({ en: "Drop a file above, or wait for a room attachment to arrive.", es: "Suelta un archivo arriba, o espera a que llegue un adjunto de la sala." })
}
/>
)}
{showList && items.length > 0 && (
<div className="space-y-2.5">
{items.map((item) => (
<FileRow
key={item.id}
item={item}
live={live}
canRetention={isAdmin}
busy={busyId === item.id}
hasActiveShare={shares[item.id] !== undefined}
onDownload={() => void handleDownload(item)}
onShare={() => { setActionError(null); setShareFor(item); }}
onRevoke={() => void revokeShare(item)}
onRetention={() => { setActionError(null); setRetentionFor(item); }}
/>
))}
</div>
)}
{live && nextCursor !== null && (
<div className="mt-4 flex justify-center">
<Button variant="outline" size="sm" disabled={loadingMore} onClick={() => void loadMore()}>
{loadingMore ? t({ en: "Loading…", es: "Cargando…" }) : t({ en: "Load more", es: "Cargar más" })}
</Button>
</div>
)}
<p className="mt-6 text-2xs leading-relaxed text-text-faint">
{t({
en: "Retention (keep / until / legal hold) is separate from link expiry: revoking or expiring a share never deletes the file, and legal hold blocks deletion until an admin releases it.",
es: "La retención (conservar / hasta / retención legal) es independiente de la caducidad del enlace: revocar o dejar caducar un enlace nunca borra el archivo, y la retención legal bloquea el borrado hasta que un admin la libere.",
})}
</p>
{shareFor !== null && (
<ShareDialog
item={shareFor}
onClose={() => setShareFor(null)}
onCreated={(itemId, share) => setShares((current) => ({ ...current, [itemId]: share }))}
/>
)}
{retentionFor !== null && (
<RetentionDialog item={retentionFor} onClose={() => setRetentionFor(null)} onSaved={handleRetentionSaved} />
)}
</div>
);
}

View file

@ -15,7 +15,7 @@ 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",
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[] }>();

View file

@ -63,6 +63,7 @@ export default function MorePage(): React.ReactElement {
<Row href="/calendar" title="Calendar" />
<Row href="/analytics" title="Analytics" />
<Row href="/video" title="Video library" />
<Row href="/files" title="Files" detail="Group file library" />
<Row href="/onboarding" title="Onboarding" />
<button onClick={() => void logout()} className="flex min-h-[56px] w-full items-center px-4 text-left text-sm font-medium text-danger">Sign out</button>
</div>

View file

@ -0,0 +1,65 @@
"use client";
import { Badge, Button, Card } from "../ui/primitives";
import { formatBytes } from "../video/format";
import { MimeIcon } from "./mime-icon";
import { mimeClassLabel, ORIGIN_LABEL, ORIGIN_TONE, RETENTION_LABEL, RETENTION_TONE } from "./labels";
import { useFilesLang } from "./i18n";
import type { GroupFileItem } from "./types";
/** One file row: identity + metadata on the left, actions on the right. */
export function FileRow({ item, live, canRetention, busy, hasActiveShare, onDownload, onShare, onRevoke, onRetention }: {
item: GroupFileItem;
live: boolean;
canRetention: boolean;
busy: boolean;
hasActiveShare: boolean;
onDownload: () => void;
onShare: () => void;
onRevoke: () => void;
onRetention: () => void;
}): React.ReactElement {
const { t } = useFilesLang();
const disabled = busy || !live || !item.available;
const retentionLabel =
item.retentionMode === "until" && item.retentionUntil !== null
? `${t(RETENTION_LABEL.until)} ${new Date(item.retentionUntil).toLocaleDateString()}`
: t(RETENTION_LABEL[item.retentionMode]);
return (
<Card className="flex flex-wrap items-center gap-3 p-3">
<MimeIcon mimeClass={item.mimeClass} size={30} className="shrink-0 text-accent" />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-sm font-medium" title={item.originalName}>{item.safeName}</span>
<Badge tone={ORIGIN_TONE[item.origin]}>{t(ORIGIN_LABEL[item.origin])}</Badge>
<Badge tone={RETENTION_TONE[item.retentionMode]}>{retentionLabel}</Badge>
{!item.available && <Badge tone="danger">{t({ en: "Unavailable", es: "No disponible" })}</Badge>}
</div>
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-2xs text-text-faint">
<span>{formatBytes(item.byteSize)}</span>
<span aria-hidden="true">·</span>
<span className="truncate" title={`${t(mimeClassLabel(item.mimeClass))} · ${item.mime}`}>{t(mimeClassLabel(item.mimeClass))}</span>
<span aria-hidden="true">·</span>
<span className="max-w-[16rem] truncate" title={item.senderMxid}>{item.senderMxid}</span>
<span aria-hidden="true">·</span>
<time dateTime={item.createdAt}>{new Date(item.createdAt).toLocaleString()}</time>
</div>
</div>
<div className="flex flex-wrap items-center gap-1.5">
<Button size="sm" variant="outline" disabled={disabled} onClick={onDownload}>{t({ en: "Download", es: "Descargar" })}</Button>
<Button size="sm" variant="ghost" disabled={disabled} onClick={onShare}>{t({ en: "Share", es: "Compartir" })}</Button>
{hasActiveShare && (
<Button size="sm" variant="ghost" disabled={busy || !live} onClick={onRevoke}>
{t({ en: "Revoke link", es: "Revocar enlace" })}
</Button>
)}
{canRetention && (
<Button size="sm" variant="ghost" disabled={busy || !live} onClick={onRetention}>
{t({ en: "Retention", es: "Retención" })}
</Button>
)}
</div>
</Card>
);
}

View file

@ -0,0 +1,40 @@
import { API_URL, ApiError } from "../../lib/api-client";
import { getSessionToken } from "../../lib/session-token";
/**
* Binary download for GET /files/items/:itemId/download. This cannot use
* `apiFetch` (it parses JSON) or a bare anchor (the endpoint needs the
* Authorization header, which anchors cannot send), so it replays the client's
* auth rules: same base URL, Bearer token, cookie credentials — then streams
* the body into a Blob and clicks a temporary anchor.
*/
export async function downloadGroupFile(itemId: string, fileName: string): Promise<void> {
const token = getSessionToken();
const response = await fetch(`${API_URL}/files/items/${encodeURIComponent(itemId)}/download`, {
credentials: "include",
headers: token === null ? {} : { Authorization: `Bearer ${token}` },
});
if (!response.ok) {
if (response.status === 401) throw new ApiError(401, "Your session expired — sign in again.", null);
throw new ApiError(response.status, `Download failed (HTTP ${response.status})`, null);
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = fileName;
document.body.append(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
}
/**
* Derive the share id from a public tokenUrl of the form
* `/files/shares/:token/download`, used for DELETE /files/shares/:shareId when
* the POST response omits an explicit shareId.
*/
export function shareIdFromUrl(tokenUrl: string): string | null {
const match = /\/files\/shares\/([^/?#]+)/.exec(tokenUrl);
return match?.[1] !== undefined && match[1] !== "" ? match[1] : null;
}

View file

@ -0,0 +1,66 @@
"use client";
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
/**
* EN/ES language toggle for the Files section, mirroring
* app/(main)/wiki/lang-context.tsx exactly: a per-browser reading preference
* in localStorage (never sent to the server) and a `t({en, es})` helper every
* Files label goes through. Wired by app/(main)/files/layout.tsx.
*/
export type FilesLang = "en" | "es";
export interface LocalizedText {
readonly en: string;
readonly es: string;
}
interface FilesLangContextValue {
lang: FilesLang;
setLang: (lang: FilesLang) => void;
/** Pick the string for the current language — the one helper every Files screen uses. */
t: (text: LocalizedText) => string;
}
const STORAGE_KEY = "tmbh.files.lang";
const FilesLangContext = createContext<FilesLangContextValue | null>(null);
function readStoredLang(): FilesLang {
if (typeof window === "undefined") return "en";
try {
const stored = window.localStorage.getItem(STORAGE_KEY);
return stored === "es" ? "es" : "en";
} catch {
return "en";
}
}
export function FilesLangProvider({ children }: { children: ReactNode }): React.ReactElement {
const [lang, setLangState] = useState<FilesLang>("en");
useEffect(() => {
setLangState(readStoredLang());
}, []);
const setLang = useCallback((next: FilesLang) => {
setLangState(next);
try {
window.localStorage.setItem(STORAGE_KEY, next);
} catch {
// Private-browsing modes can refuse storage; the choice still holds for
// this screen, it just will not survive a reload.
}
}, []);
const t = useCallback((text: LocalizedText) => text[lang], [lang]);
const value = useMemo<FilesLangContextValue>(() => ({ lang, setLang, t }), [lang, setLang, t]);
return <FilesLangContext.Provider value={value}>{children}</FilesLangContext.Provider>;
}
export function useFilesLang(): FilesLangContextValue {
const context = useContext(FilesLangContext);
if (context === null) throw new Error("useFilesLang must be used inside FilesLangProvider");
return context;
}

View file

@ -0,0 +1,43 @@
import type { LocalizedText } from "./i18n";
import type { FileOrigin, MimeClass, RetentionMode } from "./types";
/** Bilingual display labels for the group-files enums (values are fixed by the API). */
export const ORIGIN_LABEL: Record<FileOrigin, LocalizedText> = {
matrix: { en: "Matrix", es: "Matrix" },
bridge: { en: "Bridge", es: "Puente" },
upload: { en: "Upload", es: "Subido" },
"pad-export": { en: "Pad export", es: "Export. de pad" },
};
export const ORIGIN_TONE: Record<FileOrigin, "neutral" | "accent" | "live" | "warn" | "danger"> = {
matrix: "accent",
bridge: "live",
upload: "neutral",
"pad-export": "warn",
};
export const MIME_CLASS_LABEL: Record<MimeClass, LocalizedText> = {
image: { en: "Image", es: "Imagen" },
video: { en: "Video", es: "Vídeo" },
audio: { en: "Audio", es: "Audio" },
document: { en: "Document", es: "Documento" },
other: { en: "File", es: "Archivo" },
};
export const RETENTION_LABEL: Record<RetentionMode, LocalizedText> = {
keep: { en: "Keep", es: "Conservar" },
until: { en: "Until", es: "Hasta" },
legalHold: { en: "Legal hold", es: "Retención legal" },
};
export const RETENTION_TONE: Record<RetentionMode, "neutral" | "accent" | "live" | "warn" | "danger"> = {
keep: "neutral",
until: "accent",
legalHold: "danger",
};
/** Human label for a MIME class, tolerating an unknown value from the server. */
export function mimeClassLabel(mimeClass: MimeClass): LocalizedText {
return MIME_CLASS_LABEL[mimeClass] ?? MIME_CLASS_LABEL.other;
}

View file

@ -0,0 +1,66 @@
import type { MimeClass } from "./types";
/**
* MIME-class glyph for a file row. Inline SVG drawn with `currentColor` so it
* inherits the palette (text-accent in the row) instead of shipping four more
* brand assets — the sidebar nav icon is the only new static asset.
*/
export function MimeIcon({ mimeClass, size = 28, className = "" }: {
mimeClass: MimeClass;
size?: number;
className?: string;
}): React.ReactElement {
const common = {
width: size,
height: size,
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
strokeWidth: 1.6,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
"aria-hidden": true,
className,
};
if (mimeClass === "image") {
return (
<svg {...common}>
<rect x="3" y="4" width="18" height="16" rx="2" />
<circle cx="8.5" cy="9.5" r="1.5" />
<path d="M4 17l4.5-4.5a2 2 0 0 1 2.8 0L20 19" />
</svg>
);
}
if (mimeClass === "video") {
return (
<svg {...common}>
<rect x="3" y="5" width="13" height="14" rx="2" />
<path d="M16 9.5l5-2.8v10.6l-5-2.8" />
</svg>
);
}
if (mimeClass === "audio") {
return (
<svg {...common}>
<path d="M9 18V6l10-2v12" />
<circle cx="6" cy="18" r="3" />
<circle cx="16" cy="16" r="3" />
</svg>
);
}
if (mimeClass === "document") {
return (
<svg {...common}>
<path d="M6 3h7l5 5v13H6z" />
<path d="M13 3v5h5" />
<path d="M9 13h6M9 17h6" />
</svg>
);
}
return (
<svg {...common}>
<path d="M6 3h7l5 5v13H6z" />
<path d="M13 3v5h5" />
</svg>
);
}

View file

@ -0,0 +1,105 @@
"use client";
import { useState } from "react";
import { Button, Input } from "../ui/primitives";
import { api, errorMessage } from "../../lib/api-client";
import { RETENTION_LABEL } from "./labels";
import { useFilesLang } from "./i18n";
import type { GroupFileItem, RetentionInput, RetentionMode } from "./types";
/**
* Admin-only retention change: POST /files/items/:itemId/retention
* {mode, until?}. Retention is separate from share expiry — legalHold blocks
* deletion, `until` requires a deadline, `keep` is the no-purge default.
*/
export function RetentionDialog({ item, onClose, onSaved }: {
item: GroupFileItem;
onClose: () => void;
onSaved: (itemId: string, input: RetentionInput) => void;
}): React.ReactElement {
const { t } = useFilesLang();
const [mode, setMode] = useState<RetentionMode>(item.retentionMode);
const [until, setUntil] = useState(item.retentionUntil?.slice(0, 16) ?? "");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const modes: RetentionMode[] = ["keep", "until", "legalHold"];
async function save(): Promise<void> {
let iso: string | undefined;
if (mode === "until") {
if (until === "") {
setError(t({ en: "Pick a retention deadline.", es: "Elige una fecha límite de retención." }));
return;
}
const parsed = new Date(until);
if (!Number.isFinite(parsed.getTime())) {
setError(t({ en: "That deadline is not a valid date.", es: "Esa fecha límite no es válida." }));
return;
}
iso = parsed.toISOString();
}
setBusy(true);
setError(null);
try {
const body: RetentionInput = mode === "until" ? { mode, until: iso } : { mode };
await api.post(`/files/items/${item.id}/retention`, body);
onSaved(item.id, body);
onClose();
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={onClose}>
<div
role="dialog"
aria-modal="true"
aria-label={t({ en: "Set retention", es: "Definir retención" })}
className="w-[460px] max-w-[92vw] rounded-lg border border-border bg-surface p-4 shadow-2xl"
onClick={(e: React.MouseEvent) => e.stopPropagation()}
>
<h2 className="text-sm font-semibold">{t({ en: "Set retention", es: "Definir retención" })}</h2>
<p className="mt-1 truncate text-xs text-text-dim" title={item.originalName}>{item.safeName}</p>
<p className="mt-3 text-xs leading-relaxed text-text-dim">
{t({
en: "Retention is independent of share expiry. Legal hold blocks deletion until an admin releases it.",
es: "La retención es independiente de la caducidad del enlace. La retención legal bloquea el borrado hasta que un admin la libere.",
})}
</p>
<div className="mt-3 flex flex-wrap gap-1.5" role="group" aria-label={t({ en: "Retention mode", es: "Modo de retención" })}>
{modes.map((value) => (
<button
key={value}
type="button"
onClick={() => setMode(value)}
aria-pressed={mode === value}
className={`min-h-9 rounded-md border px-3 text-xs font-medium transition-colors duration-150 ${
mode === value ? "border-accent bg-accent-dim text-accent" : "border-border text-text-dim hover:border-text-faint"
}`}
>
{t(RETENTION_LABEL[value])}
</button>
))}
</div>
{mode === "until" && (
<label className="mt-3 block text-2xs text-text-faint">
{t({ en: "Retain until", es: "Conservar hasta" })}
<Input type="datetime-local" value={until} onChange={(e) => setUntil(e.target.value)} className="mt-1" />
</label>
)}
{error !== null && <p role="alert" className="mt-3 text-xs text-danger">{error}</p>}
<div className="mt-4 flex justify-end gap-2">
<Button variant="ghost" onClick={onClose}>{t({ en: "Cancel", es: "Cancelar" })}</Button>
<Button variant="primary" disabled={busy} onClick={() => void save()}>
{busy ? t({ en: "Saving…", es: "Guardando…" }) : t({ en: "Save", es: "Guardar" })}
</Button>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,158 @@
"use client";
import { useMemo, useState } from "react";
import { Button, Input } from "../ui/primitives";
import { api, errorMessage } from "../../lib/api-client";
import { shareIdFromUrl } from "./files-client";
import { useFilesLang } from "./i18n";
import type { GroupFileItem, ShareResponse } from "./types";
/**
* Mint a public share link for one file: POST /files/items/:itemId/share
* {expiresAt}. The token is returned once — after this dialog closes it can
* never be recovered, so the result view says so in plain language and offers
* a copy button before the user leaves.
*/
type ExpiryKey = "1h" | "24h" | "7d" | "custom";
const EXPIRY_MS: Record<Exclude<ExpiryKey, "custom">, number> = {
"1h": 60 * 60 * 1000,
"24h": 24 * 60 * 60 * 1000,
"7d": 7 * 24 * 60 * 60 * 1000,
};
export function ShareDialog({ item, onClose, onCreated }: {
item: GroupFileItem;
onClose: () => void;
onCreated: (itemId: string, share: { shareId: string | null; tokenUrl: string }) => void;
}): React.ReactElement {
const { t } = useFilesLang();
const [expiry, setExpiry] = useState<ExpiryKey>("24h");
const [customAt, setCustomAt] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [tokenUrl, setTokenUrl] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const options: Array<{ key: ExpiryKey; label: string }> = [
{ key: "1h", label: t({ en: "1 hour", es: "1 hora" }) },
{ key: "24h", label: t({ en: "24 hours", es: "24 horas" }) },
{ key: "7d", label: t({ en: "7 days", es: "7 días" }) },
{ key: "custom", label: t({ en: "Custom", es: "Personalizado" }) },
];
const minLocal = useMemo(() => {
const now = new Date(Date.now() - new Date().getTimezoneOffset() * 60_000);
return now.toISOString().slice(0, 16);
}, []);
function expiresAtIso(): string | null {
if (expiry === "custom") {
if (customAt === "") return null;
const parsed = new Date(customAt);
return Number.isFinite(parsed.getTime()) ? parsed.toISOString() : null;
}
return new Date(Date.now() + EXPIRY_MS[expiry]).toISOString();
}
async function create(): Promise<void> {
const expiresAt = expiresAtIso();
if (expiresAt === null) {
setError(t({ en: "Pick a future expiry date.", es: "Elige una fecha de caducidad futura." }));
return;
}
setBusy(true);
setError(null);
try {
const response = await api.post<ShareResponse>(`/files/items/${item.id}/share`, { expiresAt });
setTokenUrl(response.tokenUrl);
onCreated(item.id, { shareId: response.shareId ?? shareIdFromUrl(response.tokenUrl), tokenUrl: response.tokenUrl });
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
async function copy(): Promise<void> {
if (tokenUrl === null) return;
try {
await navigator.clipboard.writeText(tokenUrl);
setCopied(true);
} catch {
setError(t({ en: "Could not copy — select the link and copy it manually.", es: "No se pudo copiar — selecciona el enlace y cópialo a mano." }));
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={onClose}>
<div
role="dialog"
aria-modal="true"
aria-label={t({ en: "Share file", es: "Compartir archivo" })}
className="w-[460px] max-w-[92vw] rounded-lg border border-border bg-surface p-4 shadow-2xl"
onClick={(e: React.MouseEvent) => e.stopPropagation()}
>
<h2 className="text-sm font-semibold">{t({ en: "Share file", es: "Compartir archivo" })}</h2>
<p className="mt-1 truncate text-xs text-text-dim" title={item.originalName}>{item.safeName}</p>
{tokenUrl === null ? (
<>
<p className="mt-3 text-xs leading-relaxed text-text-dim">
{t({
en: "Anyone with the link can download this file until it expires. Expiry only ends the link — it never deletes or hides the file.",
es: "Cualquiera con el enlace puede descargar este archivo hasta que caduque. La caducidad solo termina el enlace — nunca borra ni oculta el archivo.",
})}
</p>
<div className="mt-3 flex flex-wrap gap-1.5" role="group" aria-label={t({ en: "Link expiry", es: "Caducidad del enlace" })}>
{options.map((option) => (
<button
key={option.key}
type="button"
onClick={() => setExpiry(option.key)}
aria-pressed={expiry === option.key}
className={`min-h-9 rounded-md border px-3 text-xs font-medium transition-colors duration-150 ${
expiry === option.key ? "border-accent bg-accent-dim text-accent" : "border-border text-text-dim hover:border-text-faint"
}`}
>
{option.label}
</button>
))}
</div>
{expiry === "custom" && (
<label className="mt-3 block text-2xs text-text-faint">
{t({ en: "Expires at", es: "Caduca el" })}
<Input type="datetime-local" min={minLocal} value={customAt} onChange={(e) => setCustomAt(e.target.value)} className="mt-1" />
</label>
)}
{error !== null && <p role="alert" className="mt-3 text-xs text-danger">{error}</p>}
<div className="mt-4 flex justify-end gap-2">
<Button variant="ghost" onClick={onClose}>{t({ en: "Cancel", es: "Cancelar" })}</Button>
<Button variant="primary" disabled={busy} onClick={() => void create()}>
{busy ? t({ en: "Creating…", es: "Creando…" }) : t({ en: "Create link", es: "Crear enlace" })}
</Button>
</div>
</>
) : (
<>
<p role="status" className="mt-3 text-xs leading-relaxed text-warn">
{t({
en: "Copy this link now — it is shown only once and cannot be recovered after you close this dialog.",
es: "Copia este enlace ahora — se muestra solo una vez y no se puede recuperar tras cerrar este cuadro.",
})}
</p>
<Input readOnly value={tokenUrl} aria-label={t({ en: "Share link", es: "Enlace para compartir" })} onFocus={(e) => e.target.select()} className="mt-2 font-mono text-2xs" />
{error !== null && <p role="alert" className="mt-2 text-xs text-danger">{error}</p>}
<div className="mt-4 flex justify-end gap-2">
<Button variant="ghost" onClick={onClose}>{t({ en: "Close", es: "Cerrar" })}</Button>
<Button variant={copied ? "outline" : "primary"} onClick={() => void copy()}>
{copied ? t({ en: "Copied", es: "Copiado" }) : t({ en: "Copy link", es: "Copiar enlace" })}
</Button>
</div>
</>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,64 @@
/**
* Wire shapes for the group-files HTTP surface (apps/api/src/group-files).
* Kept local to the Files feature so it does not collide with the shared
* api-types.ts other builders may be editing in parallel. Hand-synced with the
* controller: GET /files/groups/:groupId/items returns the flat item below.
*/
export type FileOrigin = "matrix" | "bridge" | "upload" | "pad-export";
/**
* Coarse MIME class. NOTE: the API's DB CHECK (migration 029) and
* group-files.validation.ts MIME_CLASSES both use
* "image" | "video" | "audio" | "document" | "other" — the task brief said
* image/video/audio/file/doc; this follows the backend's real enum.
*/
export type MimeClass = "image" | "video" | "audio" | "document" | "other";
export type RetentionMode = "keep" | "until" | "legalHold";
/** One row of the group file library — the served item shape. */
export interface GroupFileItem {
id: string;
groupId: string;
origin: FileOrigin;
senderMxid: string;
originalName: string;
safeName: string;
mime: string;
mimeClass: MimeClass;
byteSize: number;
sha256: string;
createdAt: string;
available: boolean;
retentionMode: RetentionMode;
retentionUntil: string | null;
}
export interface GroupFilePage {
items: GroupFileItem[];
nextCursor: string | null;
}
/** GET /groups/mine (same shape the More page already consumes). */
export interface GroupSummary {
id: string;
name: string;
}
/**
* POST /files/items/:itemId/share response. The task surface documents only
* `tokenUrl`; the API may also return a `shareId` used by
* DELETE /files/shares/:shareId. When absent the client derives the id from
* the tokenUrl's path segment so revoke still works.
*/
export interface ShareResponse {
tokenUrl: string;
shareId?: string;
}
/** POST /files/items/:itemId/retention body. */
export interface RetentionInput {
mode: RetentionMode;
until?: string;
}

View file

@ -0,0 +1,160 @@
"use client";
import { useCallback, useRef, useState } from "react";
import { Button } from "../ui/primitives";
import { Loading } from "../brand/loading";
import { apiUploadFile, errorMessage } from "../../lib/api-client";
import { formatBytes } from "../video/format";
import { useFilesLang } from "./i18n";
import type { GroupFileItem } from "./types";
/**
* Drag-drop + picker for POST /files/groups/:groupId/upload. The API accepts a
* single `file` field capped at 20 MiB; the cap is enforced here too so an
* oversized pick fails fast with a friendly message instead of a 413.
*/
const MAX_UPLOAD_BYTES = 20 * 1024 * 1024;
export function UploadDropzone({ groupId, live, onUploaded }: {
groupId: string;
live: boolean;
onUploaded: (item: GroupFileItem) => void;
}): React.ReactElement {
const { t } = useFilesLang();
const inputRef = useRef<HTMLInputElement>(null);
const busyRef = useRef(false);
const [dragging, setDragging] = useState(false);
const [uploading, setUploading] = useState<string | null>(null);
const [progress, setProgress] = useState(0);
const [error, setError] = useState<string | null>(null);
const disabled = !live || groupId === "" || uploading !== null;
const register = useCallback(
async (file: File): Promise<void> => {
if (disabled || busyRef.current) return;
if (file.size === 0) {
setError(t({ en: `${file.name}: the file is empty.`, es: `${file.name}: el archivo está vacío.` }));
return;
}
if (file.size > MAX_UPLOAD_BYTES) {
setError(
t({
en: `${file.name} is ${formatBytes(file.size)} — the limit is 20 MiB.`,
es: `${file.name} pesa ${formatBytes(file.size)} — el límite es 20 MiB.`,
}),
);
return;
}
setError(null);
busyRef.current = true;
setProgress(0);
setUploading(`${file.name} (${formatBytes(file.size)})`);
try {
const created = await apiUploadFile<GroupFileItem>(
`/files/groups/${encodeURIComponent(groupId)}/upload`,
file,
setProgress,
);
onUploaded(created);
} catch (err) {
setError(errorMessage(err));
} finally {
setUploading(null);
busyRef.current = false;
}
},
[disabled, groupId, onUploaded, t],
);
const hint = !live
? t({ en: "Offline — connect to the API to upload.", es: "Sin conexión — conéctate a la API para subir." })
: groupId === ""
? t({ en: "Choose a group first.", es: "Elige un grupo primero." })
: t({
en: "Drop a file, paste one, or browse. Up to 20 MiB; stored with re-verified bytes.",
es: "Arrastra un archivo, pega uno o búscalo. Hasta 20 MiB; guardado con bytes reverificados.",
});
return (
<div className="mb-4">
<div
role="button"
tabIndex={disabled ? -1 : 0}
aria-disabled={disabled}
aria-label={t({ en: "Upload a file", es: "Subir un archivo" })}
onClick={() => {
if (!disabled) inputRef.current?.click();
}}
onKeyDown={(e) => {
if (disabled) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
inputRef.current?.click();
}
}}
onDragOver={(e) => {
e.preventDefault();
if (!disabled) setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={(e) => {
e.preventDefault();
setDragging(false);
if (disabled) return;
const file = e.dataTransfer.files[0];
if (file !== undefined) void register(file);
}}
className={`flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed px-4 py-6 text-center transition-colors duration-150 ${
disabled
? "cursor-not-allowed border-border-subtle opacity-50"
: dragging
? "cursor-pointer border-accent bg-accent-dim"
: "cursor-pointer border-border hover:border-accent hover:bg-surface-2"
}`}
>
{uploading !== null ? (
<div className="w-full max-w-md space-y-2">
<span className="inline-flex items-center gap-2 text-xs text-text-dim">
<Loading size={14} label={t({ en: "Uploading", es: "Subiendo" })} />
{progress < 100
? t({ en: `Uploading ${progress}%`, es: `Subiendo ${progress}%` })
: t({ en: "Upload sent — indexing…", es: "Subida enviada — indexando…" })}{" "}
{uploading}
</span>
<progress className="h-2 w-full" aria-label={t({ en: "File upload progress", es: "Progreso de subida" })} value={progress} max={100} />
</div>
) : (
<>
<span className="text-sm text-text">{t({ en: "Drop a file here", es: "Suelta un archivo aquí" })}</span>
<span className="text-2xs leading-relaxed text-text-faint">{hint}</span>
{!disabled && (
<Button
size="sm"
variant="outline"
onClick={(e) => {
e.stopPropagation();
inputRef.current?.click();
}}
>
{t({ en: "Browse files", es: "Buscar archivos" })}
</Button>
)}
</>
)}
<input
ref={inputRef}
type="file"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
e.target.value = "";
if (file !== undefined) void register(file);
}}
/>
</div>
{error !== null && <p role="alert" className="mt-2 text-xs text-danger">{error}</p>}
</div>
);
}

View file

@ -38,6 +38,8 @@ function activeTab(pathname: string): string {
["/calendar", pathname.startsWith("/calendar") ? 1 : -1],
["/analytics", pathname.startsWith("/analytics") ? 1 : -1],
["/video", pathname.startsWith("/video") ? 1 : -1],
["/files", pathname.startsWith("/files") ? 1 : -1],
["/archive", pathname.startsWith("/archive") ? 1 : -1],
];
let best = "";
let bestScore = 0;

View file

@ -12,6 +12,7 @@ const PAGES = [
{ href: "/video", label: "Go to Video" },
{ href: "/inbox", label: "Go to Inbox" },
{ href: "/archive", label: "Go to Archive" },
{ href: "/files", label: "Go to Files" },
{ href: "/connections", label: "Go to Channels" },
{ href: "/team", label: "Go to Team" },
{ href: "/audit", label: "Go to Audit" },

View file

@ -17,6 +17,7 @@ const NAV = [
{ href: "/inbox", label: "Inbox" },
{ href: "/approvals", label: "Approvals" },
{ href: "/archive", label: "Archive" },
{ href: "/files", label: "Files" },
{ href: "/analytics", label: "Analytics" },
{ href: "/connections", label: "Channels" },
{ href: "/team", label: "Team" },
@ -58,8 +59,8 @@ export function Sidebar(): React.ReactElement {
}`}
>
<span className="flex items-center gap-2.5">
{item.href === "/video" || item.href === "/control-room" ? (
<NavIcon name="video" size={14} />
{item.href === "/video" || item.href === "/control-room" || item.href === "/files" ? (
<NavIcon name={item.href === "/files" ? "files" : "video"} size={14} />
) : (
<span className={`h-1.5 w-1.5 rounded-full ${active ? "bg-accent" : "bg-border group-hover:bg-text-faint"}`} />
)}