feat: adapt T-HUB features — folders schema, Instagram connector, outreach contacts, media RLS force

From the hacklab/T-HUB fork (diverged at a6843e9):
- 046_file_folders: folder trees (group + personal scope, RLS forced,
  workspace-immutability trigger) + created_by on chat_media_archive
- Instagram connector wired into connect + publishing
- outreach contacts: per-collective DM tracking (todo/sent/replied/declined),
  RLS forced, role-scoped permissions, audit without message text,
  migration renumbered 041→047
- FORCE row-level security on media_assets
The J1 api agent's folder service/controller work continues separately.
This commit is contained in:
hacklab 2026-10-15 14:15:03 +02:00 • committed by glass beadian
parent 57762f543c
commit 8f01765df3
25 changed files with 3054 additions and 4 deletions

View file

@ -8,7 +8,8 @@
"lint": "eslint src",
"typecheck": "tsc --noEmit",
"test:manual": "node --require ./scripts/control-room-register.cjs --test src/lib/participation-manual.test.ts",
"test:register": "node --require ./scripts/control-room-register.cjs --test src/lib/register-form.test.ts"
"test:register": "node --require ./scripts/control-room-register.cjs --test src/lib/register-form.test.ts",
"test:outreach": "node --require ./scripts/control-room-register.cjs --test src/lib/outreach-types.test.ts"
},
"dependencies": {
"@tactical-media-broadcast-hub/connector-sdk": "workspace:^",

View file

@ -0,0 +1,9 @@
import { FilesExplorer } from "../../../components/files/explorer/files-explorer";
/**
* Full-screen files experience. files.<domain>/ is rewritten here by
* src/middleware.ts; the route also works directly on any host.
*/
export default function FilesAppPage(): React.ReactElement {
return <FilesExplorer />;
}

View file

@ -0,0 +1,22 @@
"use client";
import type { ReactNode } from "react";
import { AuthProvider } from "../../lib/auth-store";
import { AuthGate } from "../../components/auth/auth-gate";
import { FilesLangProvider } from "../../components/files/i18n";
/**
* Shell for the dedicated files.* host. It sits OUTSIDE the (main) group on
* purpose: no hub sidebar, bottom nav or command palette — the explorer owns
* the whole viewport. Auth and the Files EN/ES preference are provided here
* the same way (main)/layout.tsx and (main)/files/layout.tsx do.
*/
export default function FilesHostLayout({ children }: { children: ReactNode }): React.ReactElement {
return (
<AuthProvider>
<FilesLangProvider>
<AuthGate>{children}</AuthGate>
</FilesLangProvider>
</AuthProvider>
);
}

View file

@ -16,7 +16,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", files: "Files", chat: "Chat",
audit: "Audit log", onboarding: "Welcome", settings: "Settings", archive: "Archive", files: "Files", chat: "Chat", outreach: "Outreach",
};
const rootTabs = new Set(["/streams", "/search", "/inbox", "/more"]);
const scrollPositions = new Map<string, { main: number; nested: number[] }>();

View file

@ -71,6 +71,7 @@ export default function MorePage(): React.ReactElement {
<h2 className="px-4 pt-6 pb-2 text-xs font-bold uppercase tracking-wider text-text-faint">Settings</h2>
<div className="border-y border-border-subtle bg-surface">
<Row href="/connections" title="Connections" detail="Accounts and health" />
<Row href="/outreach" title="Outreach" detail="Contacts to write to by hand" />
<Row href="/settings" title="Settings" />
<Row href="/groups" title="Groups" detail="Collective moderation — submissions, approvals" />
<Row href="/calendar" title="Calendar" />

View file

@ -0,0 +1,325 @@
"use client";
import { useMemo, useState } from "react";
import { Badge, Button, Card } from "../../../components/ui/primitives";
import { Loading } from "../../../components/brand/loading";
import { EmptyState } from "../../../components/brand/empty-state";
import { OfflineBadge } from "../../../components/ui/offline-badge";
import { api, errorMessage } from "../../../lib/api-client";
import { useApiResource } from "../../../lib/use-api";
import { useAuth } from "../../../lib/auth-store";
import {
OUTREACH_DAILY_SOFT_CAP,
OUTREACH_STATUSES,
fillTemplate,
parseImport,
sentToday,
type OutreachContact,
type OutreachStatus,
} from "../../../lib/outreach-types";
/**
* Outreach — write to artists, collectives and public figures one by one.
*
* The list lives in GET /outreach/contacts. For each contact the screen keeps
* a drafted message; "Copy & open" puts it on the clipboard and opens the
* profile so a person sends it from the platform's own DM box, then marks the
* contact sent. Nothing is sent by the Hub: unsolicited bulk DMs get accounts
* suspended, and a personal message is what gets an answer.
*/
const SAMPLE: OutreachContact[] = [
{
id: "sample-1", name: "Sample collective", platform: "example", handle: "@sample@example.social",
profileUrl: "https://example.social/@sample", category: "housing", language: "en", message: "", notes: "",
status: "todo", sentAt: null, repliedAt: null, createdAt: "2026-10-15T00:00:00Z", updatedAt: "2026-10-15T00:00:00Z",
},
];
const identity = (contacts: OutreachContact[]): OutreachContact[] => contacts;
const STATUS_TONE: Record<OutreachStatus, "neutral" | "accent" | "live" | "danger"> = {
todo: "neutral", sent: "accent", replied: "live", declined: "danger",
};
const TEXTAREA = "w-full rounded-md border border-border bg-surface-2 p-2 text-sm focus:border-accent focus:outline-none";
export default function OutreachPage(): React.ReactElement {
const { can } = useAuth();
const contacts = useApiResource<OutreachContact[], OutreachContact[]>("/outreach/contacts", identity, SAMPLE);
const live = contacts.source === "live";
const canManage = live && can("outreach.manage");
const [filter, setFilter] = useState<OutreachStatus | "all">("todo");
const [category, setCategory] = useState<string>("all");
const [openId, setOpenId] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [notice, setNotice] = useState<string | null>(null);
const categories = useMemo(
() => [...new Set(contacts.data.map((contact) => contact.category).filter((value): value is string => value !== null))].sort(),
[contacts.data],
);
const visible = contacts.data.filter(
(contact) => (filter === "all" || contact.status === filter) && (category === "all" || contact.category === category),
);
const counts = Object.fromEntries(
OUTREACH_STATUSES.map((status) => [status, contacts.data.filter((contact) => contact.status === status).length]),
) as Record<OutreachStatus, number>;
const today = sentToday(contacts.data);
function replace(updated: OutreachContact): void {
contacts.setData((current) => current.map((contact) => (contact.id === updated.id ? updated : contact)));
}
async function patch(id: string, body: Partial<Pick<OutreachContact, "message" | "notes" | "status">>): Promise<void> {
try {
replace(await api.patch<OutreachContact>(`/outreach/contacts/${id}`, body));
} catch (err) {
setNotice(errorMessage(err));
}
}
async function copyAndOpen(contact: OutreachContact): Promise<void> {
if (contact.message.trim() !== "") {
try {
await navigator.clipboard.writeText(contact.message);
setNotice(`Message for ${contact.name} copied. Paste it in their DMs, then mark it sent.`);
} catch {
setNotice("Could not copy to the clipboard. Select the text and copy it by hand.");
}
}
window.open(contact.profileUrl, "_blank", "noopener,noreferrer");
}
async function remove(contact: OutreachContact): Promise<void> {
if (!window.confirm(`Remove ${contact.name} from the list?`)) return;
try {
await api.del(`/outreach/contacts/${contact.id}`);
contacts.setData((current) => current.filter((row) => row.id !== contact.id));
} catch (err) {
setNotice(errorMessage(err));
}
}
async function applyTemplate(template: string): Promise<void> {
const targets = visible.filter((contact) => contact.status === "todo" && contact.message.trim() === "");
if (targets.length === 0) {
setNotice("Every pending contact in this view already has a message.");
return;
}
setBusy(true);
for (const contact of targets) await patch(contact.id, { message: fillTemplate(template, contact) });
setBusy(false);
setNotice(`Template applied to ${targets.length} contacts. Review each one before sending.`);
}
return (
<div className="h-full overflow-y-auto p-4 pb-24 scrollbar-thin lg:p-6">
<div className="mb-3 flex flex-wrap items-center gap-3">
<h1 className="text-lg font-semibold">Outreach</h1>
<OfflineBadge source={contacts.source} />
{!live && <Badge tone="warn">read-only sample</Badge>}
</div>
<p className="mb-4 max-w-2xl text-xs leading-relaxed text-text-faint">
Public accounts to write to by hand. Draft a message per contact, copy it, open the profile and send it from
the platform. Mark who answered so nobody writes twice.
</p>
<div className="mb-4 flex flex-wrap items-center gap-2">
{(["all", ...OUTREACH_STATUSES] as const).map((status) => (
<Button key={status} size="sm" variant={filter === status ? "outline" : "ghost"} onClick={() => setFilter(status)}>
{status} {status === "all" ? contacts.data.length : counts[status]}
</Button>
))}
{categories.length > 0 && (
<select
value={category}
onChange={(event) => setCategory(event.target.value)}
className="rounded-md border border-border bg-surface-2 px-2 py-1 text-2xs"
aria-label="Category"
>
<option value="all">all categories</option>
{categories.map((value) => <option key={value} value={value}>{value}</option>)}
</select>
)}
<span className="ml-auto text-2xs text-text-faint">
Sent today: <span className={today >= OUTREACH_DAILY_SOFT_CAP ? "text-warn" : "text-text-dim"}>{today}</span>
</span>
</div>
{today >= OUTREACH_DAILY_SOFT_CAP && (
<Card className="mb-4 p-3">
<p className="text-xs text-warn">
{today} first messages today from this collective. Spread the rest over the next days: platforms
restrict accounts that send many first messages in a short time.
</p>
</Card>
)}
{(notice !== null || contacts.error !== null) && (
<Card className="mb-4 flex items-start justify-between gap-3 p-3">
<p className={`text-xs ${contacts.error !== null ? "text-danger" : "text-text-dim"}`}>{contacts.error ?? notice}</p>
{notice !== null && <Button size="sm" onClick={() => setNotice(null)}>Dismiss</Button>}
</Card>
)}
{canManage && <Tools busy={busy} onImported={contacts.reload} onTemplate={applyTemplate} onNotice={setNotice} />}
{contacts.source === "loading" ? (
<div className="flex justify-center py-10"><Loading size={28} label="Loading contacts" /></div>
) : visible.length === 0 ? (
<EmptyState title="No contacts here" hint={canManage ? "Import a list above or change the filter." : "Change the filter."} />
) : (
<div className="flex flex-col gap-2">
{visible.map((contact) => (
<ContactRow
key={contact.id}
contact={contact}
open={openId === contact.id}
canManage={canManage}
onToggle={() => setOpenId(openId === contact.id ? null : contact.id)}
onPatch={(body) => patch(contact.id, body)}
onCopyOpen={() => copyAndOpen(contact)}
onRemove={() => remove(contact)}
/>
))}
</div>
)}
</div>
);
}
function ContactRow({ contact, open, canManage, onToggle, onPatch, onCopyOpen, onRemove }: {
contact: OutreachContact;
open: boolean;
canManage: boolean;
onToggle: () => void;
onPatch: (body: Partial<Pick<OutreachContact, "message" | "notes" | "status">>) => Promise<void>;
onCopyOpen: () => Promise<void>;
onRemove: () => Promise<void>;
}): React.ReactElement {
const [message, setMessage] = useState(contact.message);
const [notes, setNotes] = useState(contact.notes);
return (
<Card className="p-3">
<button type="button" onClick={onToggle} className="flex w-full flex-wrap items-center gap-2 text-left">
<span className="text-sm font-medium">{contact.name}</span>
<span className="text-2xs text-text-faint">{contact.platform} · {contact.handle}</span>
{contact.category !== null && <Badge>{contact.category}</Badge>}
{contact.language !== null && <Badge>{contact.language}</Badge>}
<span className="ml-auto"><Badge tone={STATUS_TONE[contact.status]}>{contact.status}</Badge></span>
</button>
{open && (
<div className="mt-3 flex flex-col gap-2">
<textarea
value={message}
onChange={(event) => setMessage(event.target.value)}
onBlur={() => { if (canManage && message !== contact.message) void onPatch({ message }); }}
rows={5}
maxLength={4000}
readOnly={!canManage}
placeholder="Message for this contact"
className={TEXTAREA}
/>
<textarea
value={notes}
onChange={(event) => setNotes(event.target.value)}
onBlur={() => { if (canManage && notes !== contact.notes) void onPatch({ notes }); }}
rows={2}
maxLength={2000}
readOnly={!canManage}
placeholder="Notes (who wrote, what they said)"
className={TEXTAREA}
/>
<div className="flex flex-wrap items-center gap-2">
<Button variant="primary" size="sm" onClick={() => void onCopyOpen()}>Copy &amp; open profile</Button>
{canManage && contact.status !== "sent" && <Button variant="outline" size="sm" onClick={() => void onPatch({ status: "sent" })}>Mark sent</Button>}
{canManage && contact.status !== "replied" && <Button size="sm" onClick={() => void onPatch({ status: "replied" })}>Replied</Button>}
{canManage && contact.status !== "declined" && <Button size="sm" onClick={() => void onPatch({ status: "declined" })}>Declined</Button>}
{canManage && contact.status !== "todo" && <Button size="sm" onClick={() => void onPatch({ status: "todo" })}>Back to pending</Button>}
{canManage && <Button variant="danger" size="sm" className="ml-auto" onClick={() => void onRemove()}>Remove</Button>}
</div>
{contact.sentAt !== null && (
<p className="text-2xs text-text-faint">
Sent {new Date(contact.sentAt).toLocaleString()}
{contact.repliedAt !== null && ` · replied ${new Date(contact.repliedAt).toLocaleString()}`}
</p>
)}
</div>
)}
</Card>
);
}
function Tools({ busy, onImported, onTemplate, onNotice }: {
busy: boolean;
onImported: () => void;
onTemplate: (template: string) => Promise<void>;
onNotice: (message: string) => void;
}): React.ReactElement {
const [csv, setCsv] = useState("");
const [template, setTemplate] = useState("");
const [importing, setImporting] = useState(false);
async function submit(): Promise<void> {
const { rows, errors } = parseImport(csv);
if (errors.length > 0) {
onNotice(errors.slice(0, 3).join("; "));
return;
}
if (rows.length === 0) return;
setImporting(true);
try {
let added = 0;
let skipped = 0;
for (let start = 0; start < rows.length; start += 500) {
const result = await api.post<{ added: number; skipped: number }>("/outreach/contacts", { contacts: rows.slice(start, start + 500) });
added += result.added;
skipped += result.skipped;
}
onNotice(`${added} contacts added, ${skipped} already on the list.`);
setCsv("");
onImported();
} catch (err) {
onNotice(errorMessage(err));
} finally {
setImporting(false);
}
}
return (
<div className="mb-4 grid gap-3 lg:grid-cols-2">
<Card className="p-3">
<h2 className="mb-1 text-xs font-semibold">Import</h2>
<p className="mb-2 text-2xs text-text-faint">One per line: name, platform, handle, profileUrl, category, language</p>
<textarea
value={csv}
onChange={(event) => setCsv(event.target.value)}
rows={4}
placeholder="Name,platform,handle,https://profile-url,category,es"
className={`${TEXTAREA} font-mono text-2xs`}
/>
<Button variant="outline" size="sm" className="mt-2" disabled={importing || csv.trim() === ""} onClick={() => void submit()}>
{importing ? "Importing…" : "Import"}
</Button>
</Card>
<Card className="p-3">
<h2 className="mb-1 text-xs font-semibold">Template</h2>
<p className="mb-2 text-2xs text-text-faint">
Fills pending contacts in the current view that have no message yet. Use {"{name}"} and {"{handle}"}.
</p>
<textarea
value={template}
onChange={(event) => setTemplate(event.target.value)}
rows={4}
placeholder="Hola {name}, os escribimos desde la Acampada 26S…"
className={TEXTAREA}
/>
<Button variant="outline" size="sm" className="mt-2" disabled={busy || template.trim() === ""} onClick={() => void onTemplate(template)}>
{busy ? "Applying…" : "Apply to pending"}
</Button>
</Card>
</div>
);
}

View file

@ -104,9 +104,15 @@ function ManualLanding(): React.ReactElement {
}
}
// files.* host: middleware normally rewrites "/" to the explorer; this is the
// fallback when a proxy hid the Host header from it.
useEffect(() => {
if (window.location.hostname.startsWith("files.")) router.replace("/files-app");
}, [router]);
// Signed-in visitors go straight to the app; the manual stays reachable at /wiki.
useEffect(() => {
if (status === "authenticated") router.replace("/streams");
if (status === "authenticated" && !window.location.hostname.startsWith("files.")) router.replace("/streams");
}, [status, router]);
const t = <T extends { es: string; en: string }>(text: T): string => text[lang];

View file

@ -0,0 +1,155 @@
"use client";
import { Badge, Button } from "../../ui/primitives";
import { formatBytes } from "../../video/format";
import { MimeIcon } from "../mime-icon";
import { LEGAL_HOLD_LABEL, mimeClassLabel, ORIGIN_LABEL, ORIGIN_TONE, RETENTION_LABEL, RETENTION_TONE } from "../labels";
import { useFilesLang } from "../i18n";
import type { ExplorerFile } from "./explorer-api";
/**
* Details drawer for the selected file: right rail on desktop, bottom sheet on
* mobile. Metadata on top, then every action the caller may take. Actions whose
* endpoint is not live yet render disabled with a "coming soon" note; Delete is
* not rendered at all unless the caller is permitted.
*/
export type DrawerFeature = "move" | "copy" | "shareToGroup" | "delete";
export function DetailsDrawer({
item,
live,
location,
personal,
viewable,
canRetention,
canDelete,
hasActiveShare,
busy,
unavailable,
onClose,
onView,
onDownload,
onShare,
onRevoke,
onRetention,
onMove,
onCopy,
onShareToGroup,
onDelete,
}: {
item: ExplorerFile;
live: boolean;
/** Human path of the folder the item sits in. */
location: string;
personal: boolean;
viewable: boolean;
canRetention: boolean;
canDelete: boolean;
hasActiveShare: boolean;
busy: boolean;
unavailable: ReadonlySet<DrawerFeature>;
onClose: () => void;
onView: () => void;
onDownload: () => void;
onShare: () => void;
onRevoke: () => void;
onRetention: () => void;
onMove: () => void;
onCopy: () => void;
onShareToGroup: () => void;
onDelete: () => void;
}): React.ReactElement {
const { t } = useFilesLang();
const disabled = busy || !live || item.status !== "available";
const soon = t({ en: "coming soon", es: "próximamente" });
const retentionLabel =
item.retention.mode === "until" && item.retention.expiresAt !== null
? `${t(RETENTION_LABEL.until)} ${new Date(item.retention.expiresAt).toLocaleDateString()}`
: t(RETENTION_LABEL[item.retention.mode]);
const action = (label: string, onClick: () => void, opts: { feature?: DrawerFeature; variant?: "outline" | "ghost" | "primary" | "danger"; off?: boolean } = {}): React.ReactElement => {
const missing = opts.feature !== undefined && unavailable.has(opts.feature);
return (
<Button
variant={opts.variant ?? "outline"}
disabled={(opts.off ?? disabled) || missing}
onClick={onClick}
className="min-h-11 w-full justify-center lg:min-h-9"
title={missing ? soon : undefined}
>
{label}
{missing && <span className="text-2xs font-normal text-text-faint">· {soon}</span>}
</Button>
);
};
const rows: Array<[string, React.ReactNode]> = [
[t({ en: "Type", es: "Tipo" }), `${t(mimeClassLabel(item.mimeClass))} · ${item.mimeType}`],
[t({ en: "Size", es: "Tamaño" }), formatBytes(item.sizeBytes)],
[t({ en: "Folder", es: "Carpeta" }), location],
[t({ en: "Added by", es: "Añadido por" }), item.senderId],
[t({ en: "Added", es: "Añadido" }), <time key="created" dateTime={item.createdAt}>{new Date(item.createdAt).toLocaleString()}</time>],
];
return (
<aside
aria-label={t({ en: "File details", es: "Detalles del archivo" })}
className="fixed inset-x-0 bottom-0 z-40 flex max-h-[72dvh] flex-col rounded-t-xl border-t border-border bg-surface shadow-2xl lg:static lg:z-auto lg:max-h-none lg:w-80 lg:shrink-0 lg:rounded-none lg:border-l lg:border-t-0 lg:shadow-none"
>
<div className="flex items-start gap-3 border-b border-border-subtle p-4">
<MimeIcon mimeClass={item.mimeClass} size={30} className="shrink-0 text-accent" />
<div className="min-w-0 flex-1">
<h2 className="break-all text-sm font-semibold">{item.name}</h2>
<div className="mt-1 flex flex-wrap gap-1">
<Badge tone={ORIGIN_TONE[item.origin.kind]}>{t(ORIGIN_LABEL[item.origin.kind])}</Badge>
<Badge tone={RETENTION_TONE[item.retention.mode]}>{retentionLabel}</Badge>
{item.retention.legalHold && <Badge tone="danger">{t(LEGAL_HOLD_LABEL)}</Badge>}
{personal && <Badge tone="accent">{t({ en: "Personal", es: "Personal" })}</Badge>}
</div>
</div>
<button
type="button"
onClick={onClose}
aria-label={t({ en: "Close details", es: "Cerrar detalles" })}
className="flex min-h-11 min-w-11 shrink-0 items-center justify-center rounded-md text-text-faint hover:bg-surface-2 hover:text-text lg:min-h-8 lg:min-w-8"
>
✕
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-4 scrollbar-thin">
<dl className="mb-4 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1.5 text-2xs">
{rows.map(([label, value]) => (
<div key={label} className="contents">
<dt className="text-text-faint">{label}</dt>
<dd className="min-w-0 break-all text-text-dim">{value}</dd>
</div>
))}
</dl>
<div className="grid grid-cols-2 gap-2">
{viewable && action(t({ en: "View", es: "Ver" }), onView, { variant: "primary" })}
{action(t({ en: "Download", es: "Descargar" }), onDownload, viewable ? {} : { variant: "primary" })}
{action(t({ en: "Share link", es: "Enlace" }), onShare)}
{hasActiveShare && action(t({ en: "Revoke link", es: "Revocar enlace" }), onRevoke, { variant: "ghost", off: busy || !live })}
{action(t({ en: "Move", es: "Mover" }), onMove, { feature: "move" })}
{action(t({ en: "Copy", es: "Copiar" }), onCopy, { feature: "copy" })}
{personal && action(t({ en: "Share to collective", es: "Compartir con colectivo" }), onShareToGroup, { feature: "shareToGroup" })}
{canRetention && action(t({ en: "Retention", es: "Retención" }), onRetention, { variant: "ghost", off: busy || !live })}
</div>
{canDelete && (
<div className="mt-4 border-t border-border-subtle pt-4">
{action(t({ en: "Delete…", es: "Borrar…" }), onDelete, { feature: "delete", variant: "danger", off: busy || !live || item.retention.legalHold })}
{item.retention.legalHold && (
<p className="mt-1.5 text-2xs text-text-faint">
{t({ en: "Legal hold blocks deletion until an admin releases it.", es: "La retención legal bloquea el borrado hasta que un admin la libere." })}
</p>
)}
</div>
)}
</div>
</aside>
);
}

View file

@ -0,0 +1,161 @@
import { api, ApiError } from "../../../lib/api-client";
import type { GroupFile, GroupFilePage } from "../types";
/**
* Wire calls for the files.* explorer: folders, move/copy, share-to-group,
* delete and the per-group member-delete setting. The folder/move/copy
* endpoints were landing in parallel with this screen, so every call here
* reports a 404 (or 405) as `"unavailable"` instead of throwing — the UI then
* shows a "coming soon" state for that one feature rather than an error.
*/
export interface Folder {
id: string;
name: string;
parentId: string | null;
groupId?: string | null;
personal: boolean;
}
/** Where the center pane is pointed: a group (root or folder) or the caller's personal space. */
export type FilesLocation =
| { kind: "group"; groupId: string; folderId: string | null }
| { kind: "personal"; folderId: string | null };
/** A library row as the explorer sees it: the served GroupFile plus the folder fields. */
export type ExplorerFile = GroupFile & { folderId?: string | null; personal?: boolean };
export type Maybe<T> = { ok: true; value: T } | { ok: false; unavailable: true };
export function isMissingEndpoint(err: unknown): boolean {
return err instanceof ApiError && (err.status === 404 || err.status === 405 || err.status === 501);
}
async function maybe<T>(call: () => Promise<T>): Promise<Maybe<T>> {
try {
return { ok: true, value: await call() };
} catch (err) {
if (isMissingEndpoint(err)) return { ok: false, unavailable: true };
throw err;
}
}
export function sameLocation(a: FilesLocation, b: FilesLocation): boolean {
if (a.kind !== b.kind || a.folderId !== b.folderId) return false;
return a.kind === "personal" || (b.kind === "group" && a.groupId === b.groupId);
}
export function locationKey(location: FilesLocation): string {
return location.kind === "group"
? `g:${location.groupId}:${location.folderId ?? ""}`
: `p:${location.folderId ?? ""}`;
}
export function listGroupFolders(groupId: string, signal?: AbortSignal): Promise<Maybe<Folder[]>> {
return maybe(() => api.get<Folder[]>("/files/folders", { scope: "group", groupId }, signal));
}
export function listPersonalFolders(signal?: AbortSignal): Promise<Maybe<Folder[]>> {
return maybe(() => api.get<Folder[]>("/files/folders", { scope: "personal" }, signal));
}
export function createFolder(name: string, location: FilesLocation): Promise<Maybe<Folder>> {
const scope = location.kind === "group" ? { groupId: location.groupId } : { personal: true };
const parent = location.folderId === null ? {} : { parentId: location.folderId };
return maybe(() => api.post<Folder>("/files/folders", { name, ...scope, ...parent }));
}
export function renameFolder(id: string, name: string): Promise<Maybe<Folder>> {
return maybe(() => api.patch<Folder>(`/files/folders/${encodeURIComponent(id)}`, { name }));
}
export function deleteFolder(id: string): Promise<Maybe<unknown>> {
return maybe(() => api.del<unknown>(`/files/folders/${encodeURIComponent(id)}`));
}
/** One page of the library at a location (the existing GroupFilePage shape). */
export function listFiles(
location: FilesLocation,
filters: { name: string; mimeClass: string; limit: number; cursor?: string },
signal?: AbortSignal,
): Promise<Maybe<GroupFilePage>> {
const scope = location.kind === "group" ? { groupId: location.groupId } : { scope: "personal" };
const folder = location.folderId === null ? {} : { folderId: location.folderId };
return maybe(() => api.get<GroupFilePage>("/files", { ...scope, ...folder, ...filters }, signal));
}
/** `folderId: null` moves the item back to its scope's root. */
export function moveFile(id: string, folderId: string | null): Promise<Maybe<unknown>> {
return maybe(() => api.post<unknown>(`/files/${encodeURIComponent(id)}/move`, { folderId }));
}
export type CopyTarget = { targetFolderId: string } | { targetGroupId: string };
export function copyFile(id: string, target: CopyTarget): Promise<Maybe<unknown>> {
return maybe(() => api.post<unknown>(`/files/${encodeURIComponent(id)}/copy`, target));
}
export function shareToGroup(id: string, groupId: string): Promise<Maybe<unknown>> {
return maybe(() => api.post<unknown>(`/files/${encodeURIComponent(id)}/share-to-group`, { groupId }));
}
/** DELETE /files/:id — a 403 means the delete guard refused (hold, retention, role). */
export function deleteFile(id: string): Promise<Maybe<unknown>> {
return maybe(() => api.del<unknown>(`/files/${encodeURIComponent(id)}`));
}
/**
* groups.member_delete_own for one group. The setting rides the groups
* settings API; when the endpoint or the field is absent the documented
* default is `true` (members may delete what they uploaded).
*/
export async function fetchMemberDeleteOwn(groupId: string, signal?: AbortSignal): Promise<boolean> {
const id = encodeURIComponent(groupId);
// Dedicated settings route first; until it lands, the group detail may carry the field.
for (const path of [`/groups/${id}/settings`, `/groups/${id}`]) {
try {
const body = await api.get<Record<string, unknown> | null>(path, undefined, signal);
const nested = body?.settings;
const settings = typeof nested === "object" && nested !== null ? (nested as Record<string, unknown>) : body;
const raw = settings?.member_delete_own ?? settings?.memberDeleteOwn ?? body?.member_delete_own ?? body?.memberDeleteOwn;
if (typeof raw === "boolean") return raw;
} catch {
// Missing route or no access: try the next source, then fall back to the default.
}
}
return true;
}
/** Uploads are stamped `@<username>:<server>`; the localpart identifies the caller's own files. */
export function isOwnFile(item: GroupFile, username: string | undefined): boolean {
if (username === undefined || username === "") return false;
const localpart = item.senderId.replace(/^@/, "").split(":")[0] ?? "";
return localpart.toLowerCase() === username.toLowerCase();
}
/** Flat folder list → children lookup keyed by parent id ("" = root). */
export function childrenByParent(folders: Folder[]): Map<string, Folder[]> {
const map = new Map<string, Folder[]>();
for (const folder of folders) {
const key = folder.parentId ?? "";
const list = map.get(key) ?? [];
list.push(folder);
map.set(key, list);
}
for (const list of map.values()) list.sort((a, b) => a.name.localeCompare(b.name));
return map;
}
/** Root → folder chain for breadcrumbs; stops on a broken or cyclic parent link. */
export function folderPath(folders: Folder[], folderId: string | null): Folder[] {
const byId = new Map(folders.map((folder) => [folder.id, folder]));
const path: Folder[] = [];
const seen = new Set<string>();
let current = folderId === null ? undefined : byId.get(folderId);
while (current !== undefined && !seen.has(current.id)) {
seen.add(current.id);
path.unshift(current);
current = current.parentId === null ? undefined : byId.get(current.parentId);
}
return path;
}

View file

@ -0,0 +1,237 @@
"use client";
import { useState, type ReactNode } from "react";
import { Button, Input } from "../../ui/primitives";
import { useFilesLang } from "../i18n";
import type { GroupSummary } from "../types";
import { FolderTree, type FolderState } from "./folder-tree";
import type { FilesLocation } from "./explorer-api";
/**
* Modal dialogs for the explorer: move/copy target picker, share-to-group
* group picker, folder rename and the delete confirmation. Same frame as
* ShareDialog/RetentionDialog. Every `onConfirm` resolves to `null` on success
* (the parent closes the dialog) or to an error line shown in place.
*/
function DialogFrame({ label, onClose, children }: { label: string; onClose: () => void; children: ReactNode }): React.ReactElement {
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={label}
className="flex max-h-[88dvh] w-[460px] max-w-[92vw] flex-col rounded-lg border border-border bg-surface p-4 shadow-2xl"
onClick={(e: React.MouseEvent) => e.stopPropagation()}
onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}
>
{children}
</div>
</div>
);
}
function useSubmit(onConfirm: () => Promise<string | null>): { busy: boolean; error: string | null; submit: () => Promise<void> } {
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function submit(): Promise<void> {
setBusy(true);
setError(null);
const failure = await onConfirm();
setBusy(false);
if (failure !== null) setError(failure);
}
return { busy, error, submit };
}
const FOOTER_BUTTON = "min-h-11 lg:min-h-0";
export function TargetPickerDialog({
mode,
itemName,
groups,
groupFolders,
personalFolders,
showPersonal,
isDisabled,
onClose,
onConfirm,
}: {
mode: "move" | "copy";
itemName: string;
groups: GroupSummary[];
groupFolders: Record<string, FolderState>;
personalFolders: FolderState;
showPersonal: boolean;
isDisabled: (location: FilesLocation) => boolean;
onClose: () => void;
onConfirm: (target: FilesLocation) => Promise<string | null>;
}): React.ReactElement {
const { t } = useFilesLang();
const [target, setTarget] = useState<FilesLocation | null>(null);
const { busy, error, submit } = useSubmit(() => (target === null ? Promise.resolve(null) : onConfirm(target)));
const title = mode === "move" ? t({ en: "Move to…", es: "Mover a…" }) : t({ en: "Copy to…", es: "Copiar a…" });
return (
<DialogFrame label={title} onClose={onClose}>
<h2 className="text-sm font-semibold">{title}</h2>
<p className="mt-1 truncate text-xs text-text-dim" title={itemName}>{itemName}</p>
<p className="mt-3 text-xs leading-relaxed text-text-dim">
{mode === "move"
? t({ en: "Pick a folder in the same space. Moving never changes who can see the file.", es: "Elige una carpeta del mismo espacio. Mover nunca cambia quién puede ver el archivo." })
: t({ en: "Pick a collective or folder. The copy is a new library entry; the original stays where it is.", es: "Elige un colectivo o carpeta. La copia es una entrada nueva; el original se queda donde está." })}
</p>
<div className="mt-3 min-h-0 flex-1 overflow-y-auto rounded-md border border-border-subtle bg-bg p-2 scrollbar-thin">
<FolderTree
groups={groups}
groupFolders={groupFolders}
personalFolders={personalFolders}
selected={target}
onSelect={setTarget}
showPersonal={showPersonal}
isDisabled={isDisabled}
/>
</div>
{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" className={FOOTER_BUTTON} onClick={onClose}>{t({ en: "Cancel", es: "Cancelar" })}</Button>
<Button variant="primary" className={FOOTER_BUTTON} disabled={busy || target === null} onClick={() => void submit()}>
{busy
? t({ en: "Working…", es: "Procesando…" })
: mode === "move" ? t({ en: "Move here", es: "Mover aquí" }) : t({ en: "Copy here", es: "Copiar aquí" })}
</Button>
</div>
</DialogFrame>
);
}
export function GroupPickerDialog({ itemName, groups, onClose, onConfirm }: {
itemName: string;
groups: GroupSummary[];
onClose: () => void;
onConfirm: (groupId: string) => Promise<string | null>;
}): React.ReactElement {
const { t } = useFilesLang();
const [groupId, setGroupId] = useState<string | null>(groups.length === 1 ? groups[0]?.id ?? null : null);
const { busy, error, submit } = useSubmit(() => (groupId === null ? Promise.resolve(null) : onConfirm(groupId)));
const title = t({ en: "Share to a collective", es: "Compartir con un colectivo" });
return (
<DialogFrame label={title} onClose={onClose}>
<h2 className="text-sm font-semibold">{title}</h2>
<p className="mt-1 truncate text-xs text-text-dim" title={itemName}>{itemName}</p>
<p className="mt-3 text-xs leading-relaxed text-text-dim">
{t({
en: "Every member of the collective you pick will see this file in their group library. Your personal copy stays yours.",
es: "Todas las personas del colectivo que elijas verán este archivo en su biblioteca de grupo. Tu copia personal sigue siendo tuya.",
})}
</p>
{groups.length === 0 ? (
<p className="mt-3 text-xs text-text-faint">
{t({ en: "You are not in any collective yet.", es: "Aún no estás en ningún colectivo." })}
</p>
) : (
<fieldset className="mt-3 min-h-0 flex-1 space-y-1 overflow-y-auto scrollbar-thin">
<legend className="sr-only">{t({ en: "Collective", es: "Colectivo" })}</legend>
{groups.map((group) => (
<label
key={group.id}
className={`flex min-h-11 cursor-pointer items-center gap-2 rounded-md border px-3 text-xs transition-colors duration-150 ${
groupId === group.id ? "border-accent bg-accent-dim text-accent" : "border-border text-text-dim hover:border-text-faint"
}`}
>
<input
type="radio"
name="share-group"
checked={groupId === group.id}
onChange={() => setGroupId(group.id)}
className="h-4 w-4 accent-accent"
/>
<span className="truncate">{group.name}</span>
</label>
))}
</fieldset>
)}
{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" className={FOOTER_BUTTON} onClick={onClose}>{t({ en: "Cancel", es: "Cancelar" })}</Button>
<Button variant="primary" className={FOOTER_BUTTON} disabled={busy || groupId === null} onClick={() => void submit()}>
{busy ? t({ en: "Sharing…", es: "Compartiendo…" }) : t({ en: "Share", es: "Compartir" })}
</Button>
</div>
</DialogFrame>
);
}
export function RenameFolderDialog({ current, onClose, onConfirm }: {
current: string;
onClose: () => void;
onConfirm: (name: string) => Promise<string | null>;
}): React.ReactElement {
const { t } = useFilesLang();
const [name, setName] = useState(current);
const trimmed = name.trim();
const { busy, error, submit } = useSubmit(() => onConfirm(trimmed));
const title = t({ en: "Rename folder", es: "Renombrar carpeta" });
const canSave = trimmed !== "" && trimmed !== current;
return (
<DialogFrame label={title} onClose={onClose}>
<h2 className="text-sm font-semibold">{title}</h2>
<label className="mt-3 block text-2xs text-text-faint">
{t({ en: "Name", es: "Nombre" })}
<Input
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter" && canSave) void submit(); }}
className="mt-1 min-h-11 lg:min-h-0"
/>
</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" className={FOOTER_BUTTON} onClick={onClose}>{t({ en: "Cancel", es: "Cancelar" })}</Button>
<Button variant="primary" className={FOOTER_BUTTON} disabled={busy || !canSave} onClick={() => void submit()}>
{busy ? t({ en: "Saving…", es: "Guardando…" }) : t({ en: "Save", es: "Guardar" })}
</Button>
</div>
</DialogFrame>
);
}
/** Destructive confirm that names the exact thing being deleted. */
export function ConfirmDeleteDialog({ kind, name, onClose, onConfirm }: {
kind: "file" | "folder";
name: string;
onClose: () => void;
onConfirm: () => Promise<string | null>;
}): React.ReactElement {
const { t } = useFilesLang();
const { busy, error, submit } = useSubmit(onConfirm);
const title = kind === "file" ? t({ en: "Delete file?", es: "¿Borrar archivo?" }) : t({ en: "Delete folder?", es: "¿Borrar carpeta?" });
return (
<DialogFrame label={title} onClose={onClose}>
<h2 className="text-sm font-semibold">{title}</h2>
<p className="mt-3 text-xs leading-relaxed text-text-dim">
{kind === "file"
? t({ en: "This permanently deletes", es: "Esto borra para siempre" })
: t({ en: "This deletes the empty folder", es: "Esto borra la carpeta vacía" })}{" "}
<strong className="break-all font-semibold text-text">“{name}”</strong>
{kind === "file"
? t({
en: " for everyone who can see it, and ends any share links to it. This cannot be undone.",
es: " para todas las personas que lo ven, y termina sus enlaces compartidos. No se puede deshacer.",
})
: "."}
</p>
{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" className={FOOTER_BUTTON} onClick={onClose}>{t({ en: "Cancel", es: "Cancelar" })}</Button>
<Button variant="danger" className={FOOTER_BUTTON} disabled={busy} onClick={() => void submit()}>
{busy ? t({ en: "Deleting…", es: "Borrando…" }) : t({ en: "Delete", es: "Borrar" })}
</Button>
</div>
</DialogFrame>
);
}

View file

@ -0,0 +1,786 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Button, Input } from "../../ui/primitives";
import { OfflineBadge } from "../../ui/offline-badge";
import { EmptyState } from "../../brand/empty-state";
import { Loading } from "../../brand/loading";
import { Logo } from "../../brand/logo";
import { SessionStatus } from "../../auth/auth-gate";
import { api, ApiError, ApiUnreachableError, errorMessage } from "../../../lib/api-client";
import { useAuth } from "../../../lib/auth-store";
import { useApiResource } from "../../../lib/use-api";
import { downloadFileDirect, isDirectObject, saveBytes } from "../../../lib/files-direct";
import { FileRow } from "../file-row";
import { MediaThumb } from "../media-thumb";
import { MediaViewer } from "../media-viewer";
import { MimeIcon } from "../mime-icon";
import { UploadDropzone } from "../upload-dropzone";
import { ShareDialog } from "../share-dialog";
import { RetentionDialog } from "../retention-dialog";
import { downloadGroupFile } from "../files-client";
import { useFilesLang } from "../i18n";
import { MIME_CLASS_LABEL } from "../labels";
import type { GroupFile, GroupSummary, MimeClass, RetentionInput, ShareCreated } from "../types";
import { FolderTree, type FolderState } from "./folder-tree";
import { DetailsDrawer, type DrawerFeature } from "./details-drawer";
import { ConfirmDeleteDialog, GroupPickerDialog, RenameFolderDialog, TargetPickerDialog } from "./explorer-dialogs";
import {
copyFile,
createFolder,
deleteFile,
deleteFolder,
fetchMemberDeleteOwn,
folderPath,
isOwnFile,
listFiles,
listGroupFolders,
listPersonalFolders,
locationKey,
moveFile,
renameFolder,
sameLocation,
shareToGroup,
type ExplorerFile,
type Folder,
type FilesLocation,
} from "./explorer-api";
/**
* Full-screen files experience (files.* hostname). Left rail: folder tree —
* group folders per collective + the caller's personal space. Center: the
* existing upload dropzone, grid/list and media viewer, unchanged. Right (or
* bottom sheet on mobile): details drawer with share, retention, download,
* view, copy, move, share-to-group and the guarded delete.
*
* Folder/move/copy/share-to-group endpoints may still be landing: each one
* that 404s flips its feature into a "coming soon" state instead of erroring.
*/
const PAGE_LIMIT = 50;
const SAMPLE_GROUPS: GroupSummary[] = [{ id: "sample-group", name: "Collective media" }];
const identityGroups = (groups: GroupSummary[]): GroupSummary[] => groups;
const isMedia = (item: GroupFile): boolean => item.mimeClass === "image" || item.mimeClass === "video";
type Dialog =
| { kind: "share"; item: ExplorerFile }
| { kind: "retention"; item: ExplorerFile }
| { kind: "move" | "copy"; item: ExplorerFile }
| { kind: "shareToGroup"; item: ExplorerFile }
| { kind: "deleteFile"; item: ExplorerFile }
| { kind: "renameFolder"; folder: Folder }
| { kind: "deleteFolder"; folder: Folder; location: FilesLocation };
export function FilesExplorer(): React.ReactElement {
const { t, lang, setLang } = useFilesLang();
const { status: authStatus, can, session } = useAuth();
const groups = useApiResource<GroupSummary[], GroupSummary[]>("/groups/mine", identityGroups, SAMPLE_GROUPS);
const authed = authStatus === "authenticated";
const isAdmin = can("workspace.members.manage") || session?.role === "Admin" || session?.role === "Owner";
const username = session?.user.username;
// ── folder tree ────────────────────────────────────────────────────────
const [groupFolders, setGroupFolders] = useState<Record<string, FolderState>>({});
const [personalFolders, setPersonalFolders] = useState<FolderState>(undefined);
const [memberDeleteOwn, setMemberDeleteOwn] = useState<Record<string, boolean>>({});
const [folderNonce, setFolderNonce] = useState(0);
const groupIds = useMemo(() => groups.data.map((group) => group.id).join(","), [groups.data]);
useEffect(() => {
if (!authed || groups.source !== "live") return;
const controller = new AbortController();
const ids = groupIds === "" ? [] : groupIds.split(",");
for (const id of ids) {
listGroupFolders(id, controller.signal)
.then((result) => setGroupFolders((current) => ({ ...current, [id]: result.ok ? result.value : "unavailable" })))
.catch(() => { if (!controller.signal.aborted) setGroupFolders((current) => ({ ...current, [id]: "unavailable" })); });
void fetchMemberDeleteOwn(id, controller.signal).then((allowed) => {
if (!controller.signal.aborted) setMemberDeleteOwn((current) => ({ ...current, [id]: allowed }));
});
}
listPersonalFolders(controller.signal)
.then((result) => setPersonalFolders(result.ok ? result.value : "unavailable"))
.catch(() => { if (!controller.signal.aborted) setPersonalFolders("unavailable"); });
return () => controller.abort();
}, [authed, groups.source, groupIds, folderNonce]);
const foldersFor = useCallback((location: FilesLocation): Folder[] => {
const state = location.kind === "group" ? groupFolders[location.groupId] : personalFolders;
return Array.isArray(state) ? state : [];
}, [groupFolders, personalFolders]);
// ── where the center pane points ───────────────────────────────────────
const [picked, setPicked] = useState<FilesLocation | null>(null);
const location: FilesLocation | null = useMemo(() => {
if (picked !== null) {
if (picked.kind === "personal" || groups.data.some((group) => group.id === picked.groupId)) return picked;
}
const first = groups.data[0];
if (first !== undefined) return { kind: "group", groupId: first.id, folderId: null };
return groups.source === "loading" ? null : { kind: "personal", folderId: null };
}, [picked, groups.data, groups.source]);
const scopeName = useCallback((loc: FilesLocation): string => {
if (loc.kind === "personal") return t({ en: "My files", es: "Mis archivos" });
return groups.data.find((group) => group.id === loc.groupId)?.name ?? t({ en: "Collective", es: "Colectivo" });
}, [groups.data, t]);
const crumbs = location === null ? [] : folderPath(foldersFor(location), location.folderId);
const currentFolder = crumbs.at(-1) ?? null;
const locationLabel = location === null ? "" : [scopeName(location), ...crumbs.map((folder) => folder.name)].join(" / ");
// ── listing ────────────────────────────────────────────────────────────
const [items, setItems] = useState<ExplorerFile[]>([]);
const [source, setSource] = useState<"loading" | "live" | "sample">("loading");
const [error, setError] = useState<string | null>(null);
const [listUnavailable, setListUnavailable] = useState(false);
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 [view, setView] = useState<"list" | "grid">("grid");
const live = source === "live";
useEffect(() => {
const timer = window.setTimeout(() => setQ(qInput.trim()), 300);
return () => window.clearTimeout(timer);
}, [qInput]);
const locKey = location === null ? "" : locationKey(location);
useEffect(() => {
if (authStatus === "loading" || location === null) return;
if (!authed) {
setItems([]);
setNextCursor(null);
setSource("sample");
return;
}
const controller = new AbortController();
setLoading(true);
setListUnavailable(false);
listFiles(location, { name: q, mimeClass, limit: PAGE_LIMIT }, controller.signal)
.then((result) => {
setSource("live");
setError(null);
if (!result.ok) {
setItems([]);
setNextCursor(null);
setListUnavailable(true);
return;
}
setItems(result.value.items);
setNextCursor(result.value.nextCursor);
})
.catch((err: unknown) => {
if (controller.signal.aborted) return;
if (err instanceof ApiUnreachableError) {
setItems([]);
setNextCursor(null);
setSource("sample");
return;
}
setSource("live");
// The personal scope may be rejected as a validation error until the api lands it.
if (location.kind === "personal" && err instanceof ApiError && err.status === 400) {
setItems([]);
setListUnavailable(true);
return;
}
setError(errorMessage(err));
})
.finally(() => {
if (!controller.signal.aborted) setLoading(false);
});
return () => controller.abort();
// locKey stands in for `location` (a fresh object each render).
}, [authStatus, authed, locKey, q, mimeClass, nonce]);
async function loadMore(): Promise<void> {
if (nextCursor === null || location === null) return;
setLoadingMore(true);
try {
const result = await listFiles(location, { name: q, mimeClass, limit: PAGE_LIMIT, cursor: nextCursor });
if (result.ok) {
setItems((current) => {
const seen = new Set(current.map((item) => item.id));
return [...current, ...result.value.items.filter((item) => !seen.has(item.id))];
});
setNextCursor(result.value.nextCursor);
}
} catch (err) {
setActionError(errorMessage(err));
} finally {
setLoadingMore(false);
}
}
// ── selection, actions, dialogs ────────────────────────────────────────
const [selectedId, setSelectedId] = useState<string | null>(null);
const [viewerIndex, setViewerIndex] = useState<number | null>(null);
const [dialog, setDialog] = useState<Dialog | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [busyId, setBusyId] = useState<string | null>(null);
const [shares, setShares] = useState<Record<string, ShareCreated>>({});
const [unavailable, setUnavailable] = useState<Set<DrawerFeature>>(new Set());
const [railOpen, setRailOpen] = useState(false);
const selected = items.find((item) => item.id === selectedId) ?? null;
const mediaItems = useMemo(() => items.filter(isMedia), [items]);
const personalScope = location?.kind === "personal";
const markUnavailable = (feature: DrawerFeature): string => {
setUnavailable((current) => new Set(current).add(feature));
return t({ en: "This action is coming soon — the server does not offer it yet.", es: "Esta acción llega pronto — el servidor aún no la ofrece." });
};
function navigate(next: FilesLocation): void {
setPicked(next);
setSelectedId(null);
setViewerIndex(null);
setRailOpen(false);
setActionError(null);
setNotice(null);
}
function canDelete(item: ExplorerFile): boolean {
if (isAdmin) return true;
if (personalScope || item.personal === true) return true;
// Mirrors the server guard: members may remove only their own web uploads, when the group allows it.
return item.origin.kind === "upload" && isOwnFile(item, username) && memberDeleteOwn[item.groupId] !== false;
}
const handleUploaded = useCallback((item: GroupFile): void => {
const folderId = location?.kind === "group" ? location.folderId : null;
setItems((current) => [{ ...item, folderId }, ...current.filter((existing) => existing.id !== item.id)]);
setNotice(t({ en: "File uploaded.", es: "Archivo subido." }));
// The upload lands at the collective root; file it into the open folder.
if (folderId !== null) {
moveFile(item.id, folderId)
.then((result) => {
if (!result.ok) setNotice(t({ en: "File uploaded to the collective root (folders are coming soon).", es: "Archivo subido a la raíz del colectivo (las carpetas llegan pronto)." }));
})
.catch((err: unknown) => setActionError(errorMessage(err)));
}
}, [location, t]);
async function handleDownload(item: GroupFile): Promise<void> {
setActionError(null);
try {
if (isDirectObject(item)) {
try {
saveBytes(await downloadFileDirect(item.id), item.name);
return;
} catch {
// Direct path is best-effort: fall back to streaming via the api once.
}
}
await downloadGroupFile(item.id, item.name);
} catch (err) {
setActionError(errorMessage(err));
}
}
async function revokeShare(item: GroupFile): Promise<void> {
const entry = shares[item.id];
if (entry === undefined) return;
const previous = shares;
setBusyId(item.id);
setActionError(null);
setShares((current) => {
const next = { ...current };
delete next[item.id];
return next;
});
try {
await api.del(`/files/shares/${encodeURIComponent(entry.id)}`);
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, retention: { mode: input.mode, legalHold: input.legalHold ?? false, expiresAt: input.until ?? null } }
: item,
),
);
setNotice(t({ en: "Retention updated.", es: "Retención actualizada." }));
}
async function confirmMoveOrCopy(mode: "move" | "copy", item: ExplorerFile, target: FilesLocation): Promise<string | null> {
try {
if (mode === "move") {
const result = await moveFile(item.id, target.folderId);
if (!result.ok) return markUnavailable("move");
if (location !== null && !sameLocation(location, target)) {
setItems((current) => current.filter((entry) => entry.id !== item.id));
setSelectedId(null);
}
setNotice(t({ en: `Moved to ${scopeName(target)}.`, es: `Movido a ${scopeName(target)}.` }));
} else {
const body = target.folderId !== null
? { targetFolderId: target.folderId }
: target.kind === "group" ? { targetGroupId: target.groupId } : null;
if (body === null) return t({ en: "Pick a folder or a collective.", es: "Elige una carpeta o un colectivo." });
const result = await copyFile(item.id, body);
if (!result.ok) return markUnavailable("copy");
if (location !== null && sameLocation(location, target)) setNonce((n) => n + 1);
setNotice(t({ en: `Copied to ${scopeName(target)}.`, es: `Copiado a ${scopeName(target)}.` }));
}
setDialog(null);
return null;
} catch (err) {
return errorMessage(err);
}
}
async function confirmShareToGroup(item: ExplorerFile, groupId: string): Promise<string | null> {
try {
const result = await shareToGroup(item.id, groupId);
if (!result.ok) return markUnavailable("shareToGroup");
const name = groups.data.find((group) => group.id === groupId)?.name ?? "";
setNotice(t({ en: `Shared with ${name}.`, es: `Compartido con ${name}.` }));
setDialog(null);
return null;
} catch (err) {
return errorMessage(err);
}
}
async function confirmDeleteFile(item: ExplorerFile): Promise<string | null> {
try {
const result = await deleteFile(item.id);
if (!result.ok) return markUnavailable("delete");
setItems((current) => current.filter((entry) => entry.id !== item.id));
setSelectedId(null);
setViewerIndex(null);
setNotice(t({ en: `Deleted “${item.name}”.`, es: `Borrado «${item.name}».` }));
setDialog(null);
return null;
} catch (err) {
if (err instanceof ApiError && err.status === 403) {
return t({
en: `Deletion refused: ${err.message}. Legal hold, retention or the collective's delete setting protects this file.`,
es: `Borrado rechazado: ${err.message}. La retención legal, la retención o la política del colectivo protegen este archivo.`,
});
}
return errorMessage(err);
}
}
async function handleCreateFolder(parent: FilesLocation, name: string): Promise<boolean> {
setActionError(null);
try {
const result = await createFolder(name, parent);
if (!result.ok) {
setActionError(t({ en: "Folders are coming soon — the server does not offer them yet.", es: "Las carpetas llegan pronto — el servidor aún no las ofrece." }));
return false;
}
const folder = result.value;
if (parent.kind === "group") {
setGroupFolders((current) => {
const list = current[parent.groupId];
return { ...current, [parent.groupId]: [...(Array.isArray(list) ? list : []), folder] };
});
} else {
setPersonalFolders((current) => [...(Array.isArray(current) ? current : []), folder]);
}
setNotice(t({ en: `Folder “${folder.name}” created.`, es: `Carpeta «${folder.name}» creada.` }));
return true;
} catch (err) {
setActionError(errorMessage(err));
return false;
}
}
async function confirmRenameFolder(folder: Folder, name: string): Promise<string | null> {
try {
const result = await renameFolder(folder.id, name);
if (!result.ok) return t({ en: "Renaming folders is coming soon.", es: "Renombrar carpetas llega pronto." });
setFolderNonce((n) => n + 1);
setDialog(null);
return null;
} catch (err) {
return errorMessage(err);
}
}
async function confirmDeleteFolder(folder: Folder, at: FilesLocation): Promise<string | null> {
try {
const result = await deleteFolder(folder.id);
if (!result.ok) return t({ en: "Deleting folders is coming soon.", es: "Borrar carpetas llega pronto." });
setFolderNonce((n) => n + 1);
setDialog(null);
navigate({ ...at, folderId: folder.parentId });
setNotice(t({ en: `Folder “${folder.name}” deleted.`, es: `Carpeta «${folder.name}» borrada.` }));
return null;
} catch (err) {
if (err instanceof ApiError && err.status === 409) {
return t({ en: "This folder is not empty — move or delete what is inside first.", es: "Esta carpeta no está vacía — mueve o borra su contenido primero." });
}
return errorMessage(err);
}
}
// Link back to the main hub: app.<domain> when served from files.<domain>.
const [hubHref, setHubHref] = useState("/streams");
useEffect(() => {
const { hostname, protocol, port } = window.location;
if (hostname.startsWith("files.")) setHubHref(`${protocol}//app.${hostname.slice("files.".length)}${port === "" ? "" : `:${port}`}/streams`);
}, []);
const showList = source !== "loading";
const uploadGroupId = location?.kind === "group" ? location.groupId : "";
const rail = (
<FolderTree
groups={groups.data}
groupFolders={groupFolders}
personalFolders={personalFolders}
selected={location}
onSelect={navigate}
onCreate={handleCreateFolder}
/>
);
return (
<div className="flex h-dvh min-w-0 flex-col overflow-hidden bg-bg text-text">
{/* ── top bar ── */}
<header className="flex shrink-0 items-center gap-2 border-b border-border-subtle bg-surface px-3 py-2 lg:px-4">
<button
type="button"
onClick={() => setRailOpen((open) => !open)}
aria-expanded={railOpen}
aria-label={t({ en: "Folders", es: "Carpetas" })}
className="flex min-h-11 min-w-11 items-center justify-center rounded-md text-lg text-text-dim hover:bg-surface-2 lg:hidden"
>
☰
</button>
<Logo size={24} withText={false} />
<h1 className="text-sm font-semibold">{t({ en: "Files", es: "Archivos" })}</h1>
<OfflineBadge source={source} />
<div className="ml-auto flex items-center gap-3">
<span className="hidden text-2xs text-text-faint md:inline"><SessionStatus /></span>
<a href={hubHref} className="hidden min-h-11 items-center text-2xs font-medium text-text-dim hover:text-accent sm:flex lg:min-h-0">
{t({ en: "Open the hub ›", es: "Abrir el hub ›" })}
</a>
<div className="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-10 rounded px-2.5 text-2xs font-bold uppercase transition-colors duration-150 lg:min-h-8 ${
lang === code ? "bg-accent text-white" : "text-text-dim hover:text-text"
}`}
>
{code}
</button>
))}
</div>
</div>
</header>
<div className="relative flex min-h-0 flex-1">
{/* ── left rail ── */}
<aside className="hidden w-64 shrink-0 overflow-y-auto border-r border-border-subtle bg-surface p-3 scrollbar-thin lg:block">
{rail}
</aside>
{railOpen && (
<div className="fixed inset-0 z-40 bg-black/60 lg:hidden" onClick={() => setRailOpen(false)}>
<aside
className="h-full w-[82vw] max-w-xs overflow-y-auto border-r border-border bg-surface p-3 scrollbar-thin"
onClick={(e) => e.stopPropagation()}
>
{rail}
</aside>
</div>
)}
{/* ── center ── */}
<main className="min-w-0 flex-1 overflow-y-auto p-4 pb-24 scrollbar-thin lg:p-6">
<div className="mb-4 flex flex-wrap items-center gap-2">
<nav aria-label={t({ en: "Breadcrumb", es: "Ruta" })} className="flex min-w-0 flex-wrap items-center gap-1 text-sm">
{location !== null && (
<button
type="button"
onClick={() => navigate({ ...location, folderId: null })}
className="min-h-11 truncate font-semibold hover:text-accent lg:min-h-0"
>
{scopeName(location)}
</button>
)}
{location !== null && crumbs.map((folder) => (
<span key={folder.id} className="flex items-center gap-1">
<span aria-hidden className="text-text-faint">/</span>
<button
type="button"
onClick={() => navigate({ ...location, folderId: folder.id })}
aria-current={folder.id === location.folderId ? "page" : undefined}
className="min-h-11 truncate hover:text-accent lg:min-h-0"
>
{folder.name}
</button>
</span>
))}
</nav>
{currentFolder !== null && location !== null && (
<div className="ml-auto flex gap-1">
<Button size="sm" variant="ghost" className="min-h-11 lg:min-h-0" onClick={() => setDialog({ kind: "renameFolder", folder: currentFolder })}>
{t({ en: "Rename folder", es: "Renombrar carpeta" })}
</Button>
<Button size="sm" variant="ghost" className="min-h-11 lg:min-h-0" onClick={() => setDialog({ kind: "deleteFolder", folder: currentFolder, location })}>
{t({ en: "Delete folder", es: "Borrar carpeta" })}
</Button>
</div>
)}
</div>
{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>}
{location?.kind === "group" ? (
<UploadDropzone groupId={uploadGroupId} live={live} onUploaded={handleUploaded} />
) : location?.kind === "personal" ? (
<p className="mb-4 rounded-lg border border-border-subtle bg-surface p-3 text-2xs leading-relaxed text-text-faint">
{t({
en: "Your personal space: only you see these files. Use “Share to collective” on any of them to hand it to a group library.",
es: "Tu espacio personal: solo tú ves estos archivos. Usa «Compartir con colectivo» en cualquiera para pasarlo a una biblioteca de grupo.",
})}
</p>
) : null}
<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="min-h-11 max-w-xs lg:min-h-0"
/>
<select
aria-label={t({ en: "MIME class", es: "Clase MIME" })}
value={mimeClass}
onChange={(event) => setMimeClass(event.target.value as "" | MimeClass)}
className="min-h-11 rounded-md border border-border bg-surface-2 px-2 py-1.5 text-xs lg:min-h-0"
>
<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>
<div className="flex items-center gap-1" role="group" aria-label={t({ en: "View", es: "Vista" })}>
<Button size="sm" className="min-h-11 lg:min-h-0" variant={view === "grid" ? "primary" : "outline"} aria-pressed={view === "grid"} onClick={() => setView("grid")}>{t({ en: "Grid", es: "Cuadrícula" })}</Button>
<Button size="sm" className="min-h-11 lg:min-h-0" variant={view === "list" ? "primary" : "outline"} aria-pressed={view === "list"} onClick={() => setView("list")}>{t({ en: "List", es: "Lista" })}</Button>
</div>
{live && (
<Button size="sm" variant="ghost" className="min-h-11 lg:min-h-0" onClick={() => { setNonce((n) => n + 1); setFolderNonce((n) => n + 1); }} disabled={loading}>
{t({ en: "Refresh", es: "Actualizar" })}
</Button>
)}
</div>
{(source === "loading" || location === null) && (
<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 && source === "sample" && (
<EmptyState
title={t({ en: "Offline", es: "Sin conexión" })}
hint={t({ en: "The API is unreachable — your files appear once it is back.", es: "La API no responde — tus archivos aparecerán cuando vuelva." })}
/>
)}
{showList && listUnavailable && (
<EmptyState
title={t({ en: "Coming soon", es: "Próximamente" })}
hint={t({ en: "This space is not available on the server yet. Group libraries keep working meanwhile.", es: "Este espacio aún no está disponible en el servidor. Las bibliotecas de grupo siguen funcionando." })}
/>
)}
{showList && live && 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 && live && !loading && !listUnavailable && error === null && items.length === 0 && (
<EmptyState
title={currentFolder !== null ? t({ en: "This folder is empty", es: "Esta carpeta está vacía" }) : t({ en: "No files here yet", es: "Aún no hay archivos aquí" })}
hint={
q !== "" || mimeClass !== ""
? 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." })
: personalScope
? t({ en: "Files you keep for yourself show up here.", es: "Aquí aparecen los archivos que guardas para ti." })
: t({ en: "Drop a file above, or move one here from another folder.", es: "Suelta un archivo arriba, o mueve uno aquí desde otra carpeta." })
}
/>
)}
{showList && view === "grid" && items.length > 0 && (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
{items.map((item) => (
<div
key={item.id}
className={`rounded-lg ${item.id === selectedId ? "ring-2 ring-accent ring-offset-2 ring-offset-bg" : ""}`}
>
{isMedia(item) ? (
<MediaThumb item={item} onOpen={() => setSelectedId(item.id)} />
) : (
<button
type="button"
onClick={() => setSelectedId(item.id)}
className="flex aspect-video w-full flex-col items-center justify-center gap-2 rounded-lg border border-border bg-surface-2 p-2 text-center transition-colors hover:border-accent"
>
<MimeIcon mimeClass={item.mimeClass} size={28} className="text-accent" />
<span className="w-full truncate text-2xs text-text-dim">{item.name}</span>
</button>
)}
</div>
))}
</div>
)}
{showList && view === "list" && items.length > 0 && (
<div className="space-y-2.5">
{items.map((item) => (
<div key={item.id} className={`flex items-stretch gap-1.5 rounded-lg ${item.id === selectedId ? "ring-2 ring-accent" : ""}`}>
<div className="min-w-0 flex-1">
<FileRow
item={item}
live={live}
canRetention={isAdmin}
busy={busyId === item.id}
hasActiveShare={shares[item.id] !== undefined}
onDownload={() => void handleDownload(item)}
onShare={() => { setActionError(null); setDialog({ kind: "share", item }); }}
onRevoke={() => void revokeShare(item)}
onRetention={() => { setActionError(null); setDialog({ kind: "retention", item }); }}
/>
</div>
<button
type="button"
onClick={() => setSelectedId(item.id)}
aria-label={t({ en: `Details for ${item.name}`, es: `Detalles de ${item.name}` })}
className="flex min-h-11 min-w-11 shrink-0 items-center justify-center rounded-lg border border-border-subtle bg-surface text-text-dim hover:border-accent hover:text-accent"
>
›
</button>
</div>
))}
</div>
)}
{live && nextCursor !== null && (
<div className="mt-4 flex justify-center">
<Button variant="outline" size="sm" className="min-h-11 lg:min-h-0" disabled={loadingMore} onClick={() => void loadMore()}>
{loadingMore ? t({ en: "Loading…", es: "Cargando…" }) : t({ en: "Load more", es: "Cargar más" })}
</Button>
</div>
)}
</main>
{/* ── details drawer ── */}
{selected !== null && location !== null && (
<DetailsDrawer
item={selected}
live={live}
location={locationLabel}
personal={personalScope || selected.personal === true}
viewable={isMedia(selected)}
canRetention={isAdmin}
canDelete={canDelete(selected)}
hasActiveShare={shares[selected.id] !== undefined}
busy={busyId === selected.id}
unavailable={unavailable}
onClose={() => setSelectedId(null)}
onView={() => setViewerIndex(mediaItems.findIndex((media) => media.id === selected.id))}
onDownload={() => void handleDownload(selected)}
onShare={() => { setActionError(null); setDialog({ kind: "share", item: selected }); }}
onRevoke={() => void revokeShare(selected)}
onRetention={() => { setActionError(null); setDialog({ kind: "retention", item: selected }); }}
onMove={() => setDialog({ kind: "move", item: selected })}
onCopy={() => setDialog({ kind: "copy", item: selected })}
onShareToGroup={() => setDialog({ kind: "shareToGroup", item: selected })}
onDelete={() => setDialog({ kind: "deleteFile", item: selected })}
/>
)}
</div>
{viewerIndex !== null && mediaItems[viewerIndex] !== undefined && (
<MediaViewer items={mediaItems} index={viewerIndex} onClose={() => setViewerIndex(null)} onIndex={setViewerIndex} />
)}
{dialog?.kind === "share" && (
<ShareDialog
item={dialog.item}
onClose={() => setDialog(null)}
onCreated={(itemId, share) => setShares((current) => ({ ...current, [itemId]: share }))}
/>
)}
{dialog?.kind === "retention" && (
<RetentionDialog item={dialog.item} onClose={() => setDialog(null)} onSaved={handleRetentionSaved} />
)}
{(dialog?.kind === "move" || dialog?.kind === "copy") && location !== null && (() => {
const { item, kind } = dialog;
// Move stays inside the item's own space; copy may target any collective or folder.
const moveScope: FilesLocation = location.kind === "personal" ? location : { kind: "group", groupId: item.groupId, folderId: null };
const pickerGroups = kind === "move"
? (moveScope.kind === "group" ? groups.data.filter((group) => group.id === moveScope.groupId) : [])
: groups.data;
return (
<TargetPickerDialog
mode={kind}
itemName={item.name}
groups={pickerGroups}
groupFolders={groupFolders}
personalFolders={personalFolders}
showPersonal={kind === "copy" || moveScope.kind === "personal"}
isDisabled={(target) =>
(kind === "move" && sameLocation(target, location)) ||
(kind === "copy" && target.kind === "personal" && target.folderId === null)}
onClose={() => setDialog(null)}
onConfirm={(target) => confirmMoveOrCopy(kind, item, target)}
/>
);
})()}
{dialog?.kind === "shareToGroup" && (
<GroupPickerDialog
itemName={dialog.item.name}
groups={groups.data}
onClose={() => setDialog(null)}
onConfirm={(groupId) => confirmShareToGroup(dialog.item, groupId)}
/>
)}
{dialog?.kind === "deleteFile" && (
<ConfirmDeleteDialog kind="file" name={dialog.item.name} onClose={() => setDialog(null)} onConfirm={() => confirmDeleteFile(dialog.item)} />
)}
{dialog?.kind === "renameFolder" && (
<RenameFolderDialog current={dialog.folder.name} onClose={() => setDialog(null)} onConfirm={(name) => confirmRenameFolder(dialog.folder, name)} />
)}
{dialog?.kind === "deleteFolder" && (
<ConfirmDeleteDialog kind="folder" name={dialog.folder.name} onClose={() => setDialog(null)} onConfirm={() => confirmDeleteFolder(dialog.folder, dialog.location)} />
)}
</div>
);
}

View file

@ -0,0 +1,243 @@
"use client";
import { useState } from "react";
import { Input } from "../../ui/primitives";
import { Loading } from "../../brand/loading";
import { useFilesLang } from "../i18n";
import type { GroupSummary } from "../types";
import { childrenByParent, sameLocation, type Folder, type FilesLocation } from "./explorer-api";
/**
* Folder tree: one GROUP FOLDERS section per collective the caller belongs to,
* then a PERSONAL section. Used twice — as the explorer's left rail (with
* "+ new folder") and inside the move/copy picker (selection only).
*
* Folder lists arrive per scope: `undefined` = still loading, `"unavailable"`
* = the folders endpoint is not live yet (roots stay usable, folders show a
* "coming soon" note).
*/
export type FolderState = Folder[] | "unavailable" | undefined;
const ROW =
"flex min-h-11 w-full items-center gap-1.5 rounded-md px-2 text-left text-xs transition-colors duration-150 lg:min-h-8";
export function FolderTree({
groups,
groupFolders,
personalFolders,
selected,
onSelect,
onCreate,
showPersonal = true,
isDisabled,
}: {
groups: GroupSummary[];
groupFolders: Record<string, FolderState>;
personalFolders: FolderState;
selected: FilesLocation | null;
onSelect: (location: FilesLocation) => void;
/** Present only in the rail: creates a folder under `parent`. */
onCreate?: (parent: FilesLocation, name: string) => Promise<boolean>;
showPersonal?: boolean;
/** Picker mode: grey out targets that make no sense (e.g. the item's current folder). */
isDisabled?: (location: FilesLocation) => boolean;
}): React.ReactElement {
const { t } = useFilesLang();
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
const [creatingAt, setCreatingAt] = useState<FilesLocation | null>(null);
const [draft, setDraft] = useState("");
const [busy, setBusy] = useState(false);
function toggle(key: string): void {
setCollapsed((current) => {
const next = new Set(current);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
}
async function submitDraft(): Promise<void> {
if (creatingAt === null || onCreate === undefined) return;
const name = draft.trim();
if (name === "") {
setCreatingAt(null);
return;
}
setBusy(true);
const created = await onCreate(creatingAt, name);
setBusy(false);
if (created) {
setCreatingAt(null);
setDraft("");
}
}
function draftRow(parent: FilesLocation, depth: number): React.ReactElement | null {
if (creatingAt === null || !sameLocation(creatingAt, parent)) return null;
return (
<li style={{ paddingLeft: `${depth * 12 + 8}px` }} className="py-1 pr-1">
<Input
autoFocus
value={draft}
disabled={busy}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") void submitDraft();
if (event.key === "Escape") setCreatingAt(null);
}}
onBlur={() => void submitDraft()}
placeholder={t({ en: "Folder name", es: "Nombre de la carpeta" })}
aria-label={t({ en: "New folder name", es: "Nombre de la nueva carpeta" })}
className="min-h-11 text-xs lg:min-h-8"
/>
</li>
);
}
function newFolderButton(parent: FilesLocation, available: boolean): React.ReactElement | null {
if (onCreate === undefined) return null;
return (
<button
type="button"
disabled={!available}
onClick={() => { setDraft(""); setCreatingAt(parent); }}
title={available ? undefined : t({ en: "Folders are coming soon", es: "Las carpetas llegan pronto" })}
aria-label={t({ en: "New folder", es: "Nueva carpeta" })}
className="flex min-h-11 min-w-11 shrink-0 items-center justify-center rounded-md text-sm text-text-faint transition-colors duration-150 hover:bg-surface-2 hover:text-accent disabled:opacity-40 lg:min-h-8 lg:min-w-8"
>
+
</button>
);
}
function nodeRow(location: FilesLocation, label: string, depth: number, expandKey: string | null, hasChildren: boolean, available: boolean): React.ReactElement {
const active = selected !== null && sameLocation(selected, location);
const disabled = isDisabled?.(location) ?? false;
const open = expandKey !== null && !collapsed.has(expandKey);
return (
<div className="flex items-center" style={{ paddingLeft: `${depth * 12}px` }}>
{hasChildren && expandKey !== null ? (
<button
type="button"
onClick={() => toggle(expandKey)}
aria-expanded={open}
aria-label={open ? t({ en: `Collapse ${label}`, es: `Contraer ${label}` }) : t({ en: `Expand ${label}`, es: `Expandir ${label}` })}
className="flex min-h-11 w-7 shrink-0 items-center justify-center text-2xs text-text-faint hover:text-text lg:min-h-8"
>
{open ? "▾" : "▸"}
</button>
) : (
<span className="w-7 shrink-0" aria-hidden />
)}
<button
type="button"
disabled={disabled}
onClick={() => onSelect(location)}
aria-current={active ? "true" : undefined}
className={`${ROW} min-w-0 flex-1 ${
active ? "bg-accent-dim font-medium text-accent" : "text-text-dim hover:bg-surface-2 hover:text-text"
} disabled:opacity-40`}
>
<span aria-hidden>{location.folderId === null ? "▣" : "▢"}</span>
<span className="truncate">{label}</span>
</button>
{newFolderButton(location, available)}
</div>
);
}
function folderNodes(folders: Folder[], parentKey: string, base: (folderId: string) => FilesLocation, depth: number): React.ReactElement[] {
const children = childrenByParent(folders);
const render = (parentId: string, level: number): React.ReactElement[] =>
(children.get(parentId) ?? []).map((folder) => {
const location = base(folder.id);
const key = `${parentKey}/${folder.id}`;
const kids = children.get(folder.id) ?? [];
const showDraft = creatingAt !== null && sameLocation(creatingAt, location);
return (
<li key={folder.id}>
{nodeRow(location, folder.name, level, key, kids.length > 0 || showDraft, true)}
{!collapsed.has(key) && (
<ul>
{draftRow(location, level + 1)}
{render(folder.id, level + 1)}
</ul>
)}
</li>
);
});
return render("", depth);
}
function scopeBody(state: FolderState, root: FilesLocation, rootKey: string, base: (folderId: string) => FilesLocation): React.ReactElement | null {
if (collapsed.has(rootKey)) return null;
return (
<ul>
{draftRow(root, 1)}
{state === undefined ? (
<li className="flex items-center gap-2 py-1 pl-9 text-2xs text-text-faint">
<Loading size={10} label={t({ en: "Loading folders", es: "Cargando carpetas" })} />
</li>
) : state === "unavailable" ? (
onCreate !== undefined ? (
<li className="py-1 pl-9 text-2xs text-text-faint">{t({ en: "Folders coming soon", es: "Carpetas: próximamente" })}</li>
) : null
) : (
folderNodes(state, rootKey, base, 1)
)}
</ul>
);
}
return (
<nav aria-label={t({ en: "Folders", es: "Carpetas" })} className="space-y-4">
<section>
<h2 className="mb-1 px-2 text-2xs font-bold uppercase tracking-wider text-text-faint">
{t({ en: "Group folders", es: "Carpetas de grupo" })}
</h2>
{groups.length === 0 ? (
<p className="px-2 text-2xs leading-relaxed text-text-faint">
{t({ en: "Join or create a collective to get shared folders.", es: "Únete o crea un colectivo para tener carpetas compartidas." })}
</p>
) : (
<ul>
{groups.map((group) => {
const state = groupFolders[group.id];
const root: FilesLocation = { kind: "group", groupId: group.id, folderId: null };
const rootKey = `g:${group.id}`;
const hasChildren = Array.isArray(state) ? state.length > 0 : state === undefined || onCreate !== undefined;
return (
<li key={group.id}>
{nodeRow(root, group.name, 0, rootKey, hasChildren || (creatingAt !== null && sameLocation(creatingAt, root)), Array.isArray(state))}
{scopeBody(state, root, rootKey, (folderId) => ({ kind: "group", groupId: group.id, folderId }))}
</li>
);
})}
</ul>
)}
</section>
{showPersonal && (
<section>
<h2 className="mb-1 px-2 text-2xs font-bold uppercase tracking-wider text-text-faint">
{t({ en: "Personal", es: "Personal" })}
</h2>
<ul>
{(() => {
const root: FilesLocation = { kind: "personal", folderId: null };
const rootKey = "p";
const hasChildren = Array.isArray(personalFolders) ? personalFolders.length > 0 : personalFolders === undefined || onCreate !== undefined;
return (
<li>
{nodeRow(root, t({ en: "My files", es: "Mis archivos" }), 0, rootKey, hasChildren || (creatingAt !== null && sameLocation(creatingAt, root)), Array.isArray(personalFolders))}
{scopeBody(personalFolders, root, rootKey, (folderId) => ({ kind: "personal", folderId }))}
</li>
);
})()}
</ul>
</section>
)}
</nav>
);
}

View file

@ -9,6 +9,7 @@ import type { DraftRecord } from "../../lib/api-types";
import { LogoMark } from "../brand/logo";
import { NavIcon } from "../brand";
<<<<<<< HEAD
/** Grouped per the 2026-10 UX audit (16 flat items → 5 scannable sections). */
const NAV: { section: string; items: { href: string; label: string; icon: string; external?: boolean }[] }[] = [
{
@ -47,6 +48,26 @@ const NAV: { section: string; items: { href: string; label: string; icon: string
{ href: "/settings", label: "Settings", icon: "settings" },
],
},
=======
const NAV: { href: string; label: string; icon: string; external?: boolean }[] = [
{ href: "/streams", label: "Streams", icon: "video" },
{ href: "/control-room", label: "Control Room", icon: "video" },
{ href: "/compose", label: "Compose", icon: "compose" },
{ href: "/calendar", label: "Calendar", icon: "calendar" },
{ href: "/chat", label: "Chat", icon: "chat" },
{ href: "/files", label: "Files", icon: "files" },
{ href: "/archive", label: "Archive", icon: "files" },
{ href: "/inbox", label: "Inbox", icon: "inbox" },
{ href: "/outreach", label: "Outreach", icon: "team" },
{ href: "/approvals", label: "Approvals", icon: "approvals" },
{ href: "/video", label: "Video", icon: "video" },
{ href: "/analytics", label: "Analytics", icon: "analytics" },
{ href: "/connections", label: "Channels", icon: "connections" },
{ href: "/team", label: "Team", icon: "team" },
{ href: "/audit", label: "Audit log", icon: "audit" },
{ href: "/onboarding", label: "Onboarding", icon: "home" },
{ href: "/settings", label: "Settings", icon: "settings" },
>>>>>>> d310385 (feat: outreach contacts — track hand-written DMs per collective)
];
const identity = (drafts: DraftRecord[]): DraftRecord[] => drafts;

View file

@ -0,0 +1,30 @@
import { strict as assert } from "node:assert";
import { test } from "node:test";
import { fillTemplate, parseImport, sentToday, type OutreachContact } from "./outreach-types";
test("parseImport reads quoted fields, skips header, blanks and comments", () => {
const { rows, errors } = parseImport([
"name,platform,handle,profileUrl,category,language",
"# afines",
"",
'"Stop the War, UK",Web,@STWuk,https://example.org/stw,paz,en',
].join("\n"));
assert.deepEqual(errors, []);
assert.deepEqual(rows, [{ name: "Stop the War, UK", platform: "web", handle: "@STWuk", profileUrl: "https://example.org/stw", category: "paz", language: "en" }]);
});
test("parseImport reports missing columns and non-https links by line", () => {
const { rows, errors } = parseImport("A,web,@a\nB,web,@b,http://example.org/b");
assert.deepEqual(rows, []);
assert.deepEqual(errors, ["line 1: needs name, platform, handle and profileUrl", "line 2: profileUrl must start with https://"]);
});
test("fillTemplate replaces every placeholder", () => {
assert.equal(fillTemplate("Hola {name} ({handle}), {name}", { name: "DAL", handle: "@dal" }), "Hola DAL (@dal), DAL");
});
test("sentToday counts first messages sent on the same local day", () => {
const now = new Date(2026, 9, 15, 18);
const at = (d: Date | null) => ({ sentAt: d === null ? null : d.toISOString() }) as OutreachContact;
assert.equal(sentToday([at(new Date(2026, 9, 15, 9)), at(new Date(2026, 9, 14, 23)), at(null)], now), 1);
});

View file

@ -0,0 +1,93 @@
/** Mirrors apps/api/src/outreach/outreach.service.ts (OutreachContact). */
export const OUTREACH_STATUSES = ["todo", "sent", "replied", "declined"] as const;
export type OutreachStatus = (typeof OUTREACH_STATUSES)[number];
export interface OutreachContact {
id: string;
name: string;
platform: string;
handle: string;
profileUrl: string;
category: string | null;
language: string | null;
message: string;
notes: string;
status: OutreachStatus;
sentAt: string | null;
repliedAt: string | null;
createdAt: string;
updatedAt: string;
}
export interface OutreachImportRow {
name: string;
platform: string;
handle: string;
profileUrl: string;
category?: string;
language?: string;
}
/** First messages per day above which platforms start flagging an account. */
export const OUTREACH_DAILY_SOFT_CAP = 20;
export const IMPORT_COLUMNS = ["name", "platform", "handle", "profileUrl", "category", "language"] as const;
/**
* Parse pasted CSV (one contact per line, IMPORT_COLUMNS order, optional
* header). Double quotes wrap fields that contain commas. Blank lines and
* lines starting with # are skipped. Returns rows plus per-line errors.
*/
export function parseImport(text: string): { rows: OutreachImportRow[]; errors: string[] } {
const rows: OutreachImportRow[] = [];
const errors: string[] = [];
text.split(/\r?\n/).forEach((line, index) => {
const trimmed = line.trim();
if (trimmed === "" || trimmed.startsWith("#")) return;
const cells = splitCsvLine(trimmed);
if (index === 0 && cells[0]?.toLowerCase() === "name") return;
const [name, platform, handle, profileUrl, category, language] = cells;
if (!name || !platform || !handle || !profileUrl) {
errors.push(`line ${index + 1}: needs name, platform, handle and profileUrl`);
return;
}
if (!profileUrl.startsWith("https://")) {
errors.push(`line ${index + 1}: profileUrl must start with https://`);
return;
}
const row: OutreachImportRow = { name, platform: platform.toLowerCase(), handle, profileUrl };
if (category) row.category = category;
if (language) row.language = language;
rows.push(row);
});
return { rows, errors };
}
function splitCsvLine(line: string): string[] {
const cells: string[] = [];
let current = "";
let quoted = false;
for (let i = 0; i < line.length; i += 1) {
const char = line[i];
if (quoted) {
if (char === '"' && line[i + 1] === '"') { current += '"'; i += 1; }
else if (char === '"') quoted = false;
else current += char;
} else if (char === '"') quoted = true;
else if (char === ",") { cells.push(current.trim()); current = ""; }
else current += char;
}
cells.push(current.trim());
return cells;
}
/** Fill a template: {name} and {handle} are replaced per contact. */
export function fillTemplate(template: string, contact: Pick<OutreachContact, "name" | "handle">): string {
return template.replaceAll("{name}", contact.name).replaceAll("{handle}", contact.handle);
}
export function sentToday(contacts: OutreachContact[], now = new Date()): number {
const day = now.toDateString();
return contacts.filter((contact) => contact.sentAt !== null && new Date(contact.sentAt).toDateString() === day).length;
}

View file

@ -0,0 +1,27 @@
import { NextResponse, type NextRequest } from "next/server";
/**
* Hostname routing: files.<domain> serves the full-screen files explorer.
* The root of a files.* host is rewritten (URL stays "/") to /files-app;
* /streams (where sign-in and onboarding land) redirects back to "/" so a
* fresh sign-in on the files host returns to the explorer. Every other path —
* /onboarding, /login, /join — passes through so auth still works there. The
* landing page carries a client-side fallback for proxies that drop the Host
* header.
*/
export function middleware(request: NextRequest): NextResponse {
const host = (request.headers.get("x-forwarded-host") ?? request.headers.get("host") ?? "").toLowerCase();
if (host.startsWith("files.")) {
const url = request.nextUrl.clone();
if (url.pathname === "/streams") {
// Absolute target from the public host, not the internal origin behind the tunnel.
const proto = request.headers.get("x-forwarded-proto") ?? url.protocol.replace(":", "");
return NextResponse.redirect(new URL("/", `${proto}://${host}`));
}
url.pathname = "/files-app";
return NextResponse.rewrite(url);
}
return NextResponse.next();
}
export const config = { matcher: ["/", "/streams"] };