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:
parent
57762f543c
commit
8f01765df3
25 changed files with 3054 additions and 4 deletions
|
|
@ -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:^",
|
||||
|
|
|
|||
9
apps/web/src/app/(files-host)/files-app/page.tsx
Normal file
9
apps/web/src/app/(files-host)/files-app/page.tsx
Normal 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 />;
|
||||
}
|
||||
22
apps/web/src/app/(files-host)/layout.tsx
Normal file
22
apps/web/src/app/(files-host)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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[] }>();
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
325
apps/web/src/app/(main)/outreach/page.tsx
Normal file
325
apps/web/src/app/(main)/outreach/page.tsx
Normal 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 & 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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];
|
||||
|
|
|
|||
155
apps/web/src/components/files/explorer/details-drawer.tsx
Normal file
155
apps/web/src/components/files/explorer/details-drawer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
161
apps/web/src/components/files/explorer/explorer-api.ts
Normal file
161
apps/web/src/components/files/explorer/explorer-api.ts
Normal 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;
|
||||
}
|
||||
237
apps/web/src/components/files/explorer/explorer-dialogs.tsx
Normal file
237
apps/web/src/components/files/explorer/explorer-dialogs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
786
apps/web/src/components/files/explorer/files-explorer.tsx
Normal file
786
apps/web/src/components/files/explorer/files-explorer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
243
apps/web/src/components/files/explorer/folder-tree.tsx
Normal file
243
apps/web/src/components/files/explorer/folder-tree.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
30
apps/web/src/lib/outreach-types.test.ts
Normal file
30
apps/web/src/lib/outreach-types.test.ts
Normal 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);
|
||||
});
|
||||
93
apps/web/src/lib/outreach-types.ts
Normal file
93
apps/web/src/lib/outreach-types.ts
Normal 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;
|
||||
}
|
||||
27
apps/web/src/middleware.ts
Normal file
27
apps/web/src/middleware.ts
Normal 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"] };
|
||||
Loading…
Add table
Add a link
Reference in a new issue