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:
parent
eb1b60f67d
commit
d58db63fe6
58 changed files with 5543 additions and 4 deletions
13
apps/web/src/app/(main)/files/layout.tsx
Normal file
13
apps/web/src/app/(main)/files/layout.tsx
Normal 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>;
|
||||
}
|
||||
383
apps/web/src/app/(main)/files/page.tsx
Normal file
383
apps/web/src/app/(main)/files/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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[] }>();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
65
apps/web/src/components/files/file-row.tsx
Normal file
65
apps/web/src/components/files/file-row.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
apps/web/src/components/files/files-client.ts
Normal file
40
apps/web/src/components/files/files-client.ts
Normal 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;
|
||||
}
|
||||
66
apps/web/src/components/files/i18n.tsx
Normal file
66
apps/web/src/components/files/i18n.tsx
Normal 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;
|
||||
}
|
||||
43
apps/web/src/components/files/labels.ts
Normal file
43
apps/web/src/components/files/labels.ts
Normal 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;
|
||||
}
|
||||
66
apps/web/src/components/files/mime-icon.tsx
Normal file
66
apps/web/src/components/files/mime-icon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
105
apps/web/src/components/files/retention-dialog.tsx
Normal file
105
apps/web/src/components/files/retention-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
158
apps/web/src/components/files/share-dialog.tsx
Normal file
158
apps/web/src/components/files/share-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
64
apps/web/src/components/files/types.ts
Normal file
64
apps/web/src/components/files/types.ts
Normal 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;
|
||||
}
|
||||
160
apps/web/src/components/files/upload-dropzone.tsx
Normal file
160
apps/web/src/components/files/upload-dropzone.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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"}`} />
|
||||
)}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue