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

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

View file

@ -0,0 +1,51 @@
-- 041: outreach — per-workspace list of public accounts to contact by hand.
--
-- The Hub never sends these messages. A person opens the contact's profile,
-- pastes the drafted text into the platform's own DM box and marks the row
-- sent; replies are marked the same way. Only public accounts belong here:
-- names and contact details of private persons stay out of the Hub.
CREATE TABLE outreach_contacts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE ON UPDATE RESTRICT,
name text NOT NULL CHECK (char_length(name) BETWEEN 1 AND 200),
platform text NOT NULL CHECK (platform ~ '^[a-z0-9][a-z0-9-]{1,31}$'),
handle text NOT NULL CHECK (char_length(handle) BETWEEN 1 AND 200),
profile_url text NOT NULL CHECK (profile_url ~ '^https://[^\s]+$' AND char_length(profile_url) <= 500),
category text CHECK (category IS NULL OR char_length(category) <= 100),
language text CHECK (language IS NULL OR language ~ '^[a-z]{2,3}(-[A-Za-z0-9]{2,8})?$'),
message text NOT NULL DEFAULT '' CHECK (char_length(message) <= 4000),
notes text NOT NULL DEFAULT '' CHECK (char_length(notes) <= 2000),
status text NOT NULL DEFAULT 'todo' CHECK (status IN ('todo', 'sent', 'replied', 'declined')),
sent_at timestamptz,
sent_by uuid REFERENCES users(id) ON DELETE SET NULL,
replied_at timestamptz,
created_by uuid REFERENCES users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (id, workspace_id)
);
CREATE UNIQUE INDEX outreach_contacts_account ON outreach_contacts (workspace_id, platform, lower(handle));
CREATE INDEX outreach_contacts_status ON outreach_contacts (workspace_id, status);
CREATE TRIGGER trg_outreach_contacts_ws_immutable BEFORE UPDATE ON outreach_contacts
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
ALTER TABLE outreach_contacts ENABLE ROW LEVEL SECURITY;
ALTER TABLE outreach_contacts FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON outreach_contacts
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
GRANT SELECT, INSERT, UPDATE, DELETE ON outreach_contacts TO sp_app;
-- Reads for anyone who already sees the inbox; edits for the roles that write drafts.
INSERT INTO role_permissions (role, permission)
SELECT 'Owner', 'outreach.view'
UNION ALL SELECT 'Admin', 'outreach.view'
UNION ALL SELECT 'Editor', 'outreach.view'
UNION ALL SELECT 'Moderator', 'outreach.view'
UNION ALL SELECT 'Owner', 'outreach.manage'
UNION ALL SELECT 'Admin', 'outreach.manage'
UNION ALL SELECT 'Editor', 'outreach.manage'
ON CONFLICT DO NOTHING;

View file

@ -46,9 +46,10 @@ import { MatrixSsoModule } from "./matrix-sso/matrix-sso.module.js";
import { OidcModule } from "./oidc/oidc.module.js";
import { SubmissionsModule } from "./submissions/submissions.module.js";
import { MediaModule } from "./media/media.module.js";
import { OutreachModule } from "./outreach/outreach.module.js";
@Module({
imports: [HealthModule, LiveMonitorModule, MediaControlModule, ProductionModule, RealtimeModule, LiveChatModule, LiveModule, VirtualChannelsModule, ChatgovModule, IntakeModule, GroupFilesModule, SettingsModule, CollectivesModule, MatrixSsoModule, OidcModule, SubmissionsModule, MediaModule],
imports: [HealthModule, LiveMonitorModule, MediaControlModule, ProductionModule, RealtimeModule, LiveChatModule, LiveModule, VirtualChannelsModule, ChatgovModule, IntakeModule, GroupFilesModule, SettingsModule, CollectivesModule, MatrixSsoModule, OidcModule, SubmissionsModule, MediaModule, OutreachModule],
controllers: [
DemoController,
PublishingController,

View file

@ -0,0 +1,141 @@
/**
* Outreach contacts (manual DM campaign tracking).
*
* GET /outreach/contacts?status= outreach.view — list (≤ 2000)
* POST /outreach/contacts outreach.manage — { contacts: [...] } bulk add, ≤ 500
* PATCH /outreach/contacts/:id outreach.manage — { message?, notes?, status? }
* DELETE /outreach/contacts/:id outreach.manage
*
* No route sends a message: see OutreachService.
*/
import {
BadRequestException,
Body,
Controller,
Delete,
Get,
HttpCode,
Inject,
Param,
Patch,
Post,
Query,
Req,
UseGuards,
} from "@nestjs/common";
import { requireObject, requireSession } from "../http/validation.js";
import { RbacGuard, RequirePermission, type AuthenticatedRequest } from "../rbac/rbac.guard.js";
import {
OUTREACH_MAX_IMPORT,
OutreachService,
isOutreachStatus,
type OutreachContact,
type OutreachContactInput,
type OutreachContactPatch,
type OutreachStatus,
} from "./outreach.service.js";
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const PLATFORM = /^[a-z0-9][a-z0-9-]{1,31}$/;
const LANGUAGE = /^[a-z]{2,3}(-[A-Za-z0-9]{2,8})?$/;
@Controller("outreach")
@UseGuards(RbacGuard)
export class OutreachController {
constructor(@Inject(OutreachService) private readonly outreach: OutreachService) {}
@Get("contacts")
@RequirePermission("outreach.view")
async list(@Req() request: AuthenticatedRequest, @Query("status") status?: string): Promise<OutreachContact[]> {
const session = requireSession(request);
if (status !== undefined && !isOutreachStatus(status)) throw new BadRequestException("unknown status");
return this.outreach.list(session.workspaceId, status as OutreachStatus | undefined);
}
@Post("contacts")
@RequirePermission("outreach.manage")
async import(@Req() request: AuthenticatedRequest, @Body() body: unknown): Promise<{ added: number; skipped: number }> {
const session = requireSession(request);
const raw = requireObject(body, "body").contacts;
if (!Array.isArray(raw) || raw.length === 0 || raw.length > OUTREACH_MAX_IMPORT) {
throw new BadRequestException(`contacts must be an array of 1 to ${OUTREACH_MAX_IMPORT} items`);
}
const contacts = raw.map((item, index) => parseContact(item, `contacts[${index}]`));
return this.outreach.import(session.workspaceId, contacts, { userId: session.userId, role: session.role });
}
@Patch("contacts/:id")
@RequirePermission("outreach.manage")
async update(@Req() request: AuthenticatedRequest, @Param("id") id: string, @Body() body: unknown): Promise<OutreachContact> {
const session = requireSession(request);
if (!UUID.test(id)) throw new BadRequestException("id must be a UUID");
return this.outreach.update(session.workspaceId, id, parsePatch(body), { userId: session.userId, role: session.role });
}
@Delete("contacts/:id")
@HttpCode(204)
@RequirePermission("outreach.manage")
async remove(@Req() request: AuthenticatedRequest, @Param("id") id: string): Promise<void> {
const session = requireSession(request);
if (!UUID.test(id)) throw new BadRequestException("id must be a UUID");
await this.outreach.remove(session.workspaceId, id, { userId: session.userId, role: session.role });
}
}
function text(value: unknown, field: string, max: number, required: boolean): string | undefined {
if (value === undefined || value === null || value === "") {
if (required) throw new BadRequestException(`${field} is required`);
return undefined;
}
if (typeof value !== "string") throw new BadRequestException(`${field} must be a string`);
const trimmed = value.trim();
if (required && trimmed === "") throw new BadRequestException(`${field} is required`);
if (trimmed.length > max) throw new BadRequestException(`${field} must be at most ${max} characters`);
return trimmed;
}
export function parseContact(value: unknown, field: string): OutreachContactInput {
const item = requireObject(value, field);
const name = text(item.name, `${field}.name`, 200, true) as string;
const platform = (text(item.platform, `${field}.platform`, 32, true) as string).toLowerCase();
if (!PLATFORM.test(platform)) throw new BadRequestException(`${field}.platform must be a lowercase id`);
const handle = text(item.handle, `${field}.handle`, 200, true) as string;
const profileUrl = text(item.profileUrl, `${field}.profileUrl`, 500, true) as string;
let url: URL;
try {
url = new URL(profileUrl);
} catch {
throw new BadRequestException(`${field}.profileUrl must be a URL`);
}
if (url.protocol !== "https:" || /\s/.test(profileUrl)) throw new BadRequestException(`${field}.profileUrl must be an https URL`);
const language = text(item.language, `${field}.language`, 12, false);
if (language !== undefined && !LANGUAGE.test(language)) throw new BadRequestException(`${field}.language must be a language tag such as es or pt-BR`);
const contact: OutreachContactInput = { name, platform, handle, profileUrl };
const category = text(item.category, `${field}.category`, 100, false);
const message = text(item.message, `${field}.message`, 4000, false);
const notes = text(item.notes, `${field}.notes`, 2000, false);
if (category !== undefined) contact.category = category;
if (language !== undefined) contact.language = language;
if (message !== undefined) contact.message = message;
if (notes !== undefined) contact.notes = notes;
return contact;
}
export function parsePatch(value: unknown): OutreachContactPatch {
const body = requireObject(value, "body");
const patch: OutreachContactPatch = {};
if (body.message !== undefined) {
if (typeof body.message !== "string" || body.message.length > 4000) throw new BadRequestException("message must be a string of at most 4000 characters");
patch.message = body.message;
}
if (body.notes !== undefined) {
if (typeof body.notes !== "string" || body.notes.length > 2000) throw new BadRequestException("notes must be a string of at most 2000 characters");
patch.notes = body.notes;
}
if (body.status !== undefined) {
if (!isOutreachStatus(body.status)) throw new BadRequestException("status must be todo, sent, replied or declined");
patch.status = body.status;
}
if (Object.keys(patch).length === 0) throw new BadRequestException("nothing to update");
return patch;
}

View file

@ -0,0 +1,13 @@
import { Module } from "@nestjs/common";
import { OutreachController } from "./outreach.controller.js";
import { OutreachService } from "./outreach.service.js";
/**
* Outreach contacts: the list of public accounts a collective writes to by
* hand, with the drafted message and the sent/replied state per contact.
*/
@Module({
controllers: [OutreachController],
providers: [OutreachService],
})
export class OutreachModule {}

View file

@ -0,0 +1,229 @@
/**
* Outreach: a workspace's list of public accounts to contact one by one.
*
* The Hub stores the contact, a drafted message and where the conversation
* stands (todo → sent → replied/declined). It never sends anything: the
* person copies the text, opens the profile and writes from the platform's
* own DM box. Platforms forbid or rate-limit unsolicited bulk DMs, so
* sending stays human and the API has no provider calls here.
*
* Audit entries record status changes and membership of the list, never the
* message text.
*/
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
import { appendTenantAudit } from "../audit/audit.service.js";
import { withTenant } from "../db/client.js";
export const OUTREACH_STATUSES = ["todo", "sent", "replied", "declined"] as const;
export type OutreachStatus = (typeof OUTREACH_STATUSES)[number];
/** One import call; larger lists go in several requests. */
export const OUTREACH_MAX_IMPORT = 500;
export const OUTREACH_MAX_LIST = 2000;
export interface OutreachContactInput {
name: string;
platform: string;
handle: string;
profileUrl: string;
category?: string;
language?: string;
message?: string;
notes?: string;
}
export interface OutreachContactPatch {
message?: string;
notes?: string;
status?: OutreachStatus;
}
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 OutreachActor {
userId: string;
role: string;
}
interface OutreachRow {
id: string;
name: string;
platform: string;
handle: string;
profile_url: string;
category: string | null;
language: string | null;
message: string;
notes: string;
status: OutreachStatus;
sent_at: Date | null;
replied_at: Date | null;
created_at: Date;
updated_at: Date;
}
const COLUMNS = "id, name, platform, handle, profile_url, category, language, message, notes, status, sent_at, replied_at, created_at, updated_at";
function fromRow(row: OutreachRow): OutreachContact {
return {
id: row.id,
name: row.name,
platform: row.platform,
handle: row.handle,
profileUrl: row.profile_url,
category: row.category,
language: row.language,
message: row.message,
notes: row.notes,
status: row.status,
sentAt: row.sent_at?.toISOString() ?? null,
repliedAt: row.replied_at?.toISOString() ?? null,
createdAt: row.created_at.toISOString(),
updatedAt: row.updated_at.toISOString(),
};
}
export function isOutreachStatus(value: unknown): value is OutreachStatus {
return typeof value === "string" && (OUTREACH_STATUSES as readonly string[]).includes(value);
}
@Injectable()
export class OutreachService {
async list(workspaceId: string, status?: OutreachStatus): Promise<OutreachContact[]> {
return withTenant(workspaceId, async (client) => {
const result = await client.query<OutreachRow>(
`SELECT ${COLUMNS} FROM outreach_contacts
WHERE ($1::text IS NULL OR status = $1)
ORDER BY lower(name), platform
LIMIT ${OUTREACH_MAX_LIST}`,
[status ?? null],
);
return result.rows.map(fromRow);
});
}
/** Bulk add; an account already on the list (same platform + handle) is skipped. */
async import(workspaceId: string, contacts: OutreachContactInput[], actor: OutreachActor): Promise<{ added: number; skipped: number }> {
if (contacts.length === 0) throw new BadRequestException("contacts must not be empty");
if (contacts.length > OUTREACH_MAX_IMPORT) {
throw new BadRequestException(`at most ${OUTREACH_MAX_IMPORT} contacts per import`);
}
const added = await withTenant(workspaceId, async (client) => {
const result = await client.query<{ id: string }>(
`INSERT INTO outreach_contacts
(workspace_id, name, platform, handle, profile_url, category, language, message, notes, created_by)
SELECT $1, c.name, c.platform, c.handle, c.profile_url, c.category, c.language,
coalesce(c.message, ''), coalesce(c.notes, ''), $2
FROM jsonb_to_recordset($3::jsonb) AS c(
name text, platform text, handle text, profile_url text,
category text, language text, message text, notes text)
ON CONFLICT (workspace_id, platform, lower(handle)) DO NOTHING
RETURNING id`,
[workspaceId, actor.userId, JSON.stringify(contacts.map(toRecord))],
);
return result.rowCount ?? result.rows.length;
});
if (added > 0) {
await appendTenantAudit(workspaceId, {
actorUserId: actor.userId,
actorRole: actor.role,
action: "outreach.imported",
targetType: "outreach_contact",
targetId: workspaceId,
diff: { added, skipped: contacts.length - added },
});
}
return { added, skipped: contacts.length - added };
}
/**
* Edit the draft/notes or move the status. Marking `sent` stamps who and
* when; `replied` stamps when; going back to `todo` clears both.
*/
async update(workspaceId: string, id: string, patch: OutreachContactPatch, actor: OutreachActor): Promise<OutreachContact> {
const { contact, previous } = await withTenant(workspaceId, async (client) => {
const current = await client.query<{ status: OutreachStatus }>(
"SELECT status FROM outreach_contacts WHERE id = $1 FOR UPDATE",
[id],
);
const before = current.rows[0];
if (before === undefined) throw new NotFoundException(`outreach contact ${id} not found`);
const status = patch.status ?? before.status;
const changed = status !== before.status;
const result = await client.query<OutreachRow>(
`UPDATE outreach_contacts SET
message = coalesce($2, message),
notes = coalesce($3, notes),
status = $4,
sent_at = CASE WHEN NOT $5 THEN sent_at WHEN $4 = 'todo' THEN NULL WHEN $4 = 'sent' THEN now() ELSE coalesce(sent_at, now()) END,
sent_by = CASE WHEN NOT $5 THEN sent_by WHEN $4 = 'todo' THEN NULL WHEN $4 = 'sent' THEN $6::uuid ELSE coalesce(sent_by, $6::uuid) END,
replied_at = CASE WHEN NOT $5 THEN replied_at WHEN $4 = 'replied' THEN now() ELSE NULL END,
updated_at = now()
WHERE id = $1
RETURNING ${COLUMNS}`,
[id, patch.message ?? null, patch.notes ?? null, status, changed, actor.userId],
);
const row = result.rows[0];
if (row === undefined) throw new NotFoundException(`outreach contact ${id} not found`);
return { contact: fromRow(row), previous: before.status };
});
if (contact.status !== previous) {
await appendTenantAudit(workspaceId, {
actorUserId: actor.userId,
actorRole: actor.role,
action: `outreach.${contact.status}`,
targetType: "outreach_contact",
targetId: contact.id,
diff: { from: previous, to: contact.status, platform: contact.platform, handle: contact.handle },
});
}
return contact;
}
async remove(workspaceId: string, id: string, actor: OutreachActor): Promise<void> {
const removed = await withTenant(workspaceId, async (client) => {
const result = await client.query<{ platform: string; handle: string }>(
"DELETE FROM outreach_contacts WHERE id = $1 RETURNING platform, handle",
[id],
);
return result.rows[0];
});
if (removed === undefined) throw new NotFoundException(`outreach contact ${id} not found`);
await appendTenantAudit(workspaceId, {
actorUserId: actor.userId,
actorRole: actor.role,
action: "outreach.removed",
targetType: "outreach_contact",
targetId: id,
diff: { platform: removed.platform, handle: removed.handle },
});
}
}
function toRecord(contact: OutreachContactInput): Record<string, string | null> {
return {
name: contact.name,
platform: contact.platform,
handle: contact.handle,
profile_url: contact.profileUrl,
category: contact.category ?? null,
language: contact.language ?? null,
message: contact.message ?? null,
notes: contact.notes ?? null,
};
}

View file

@ -0,0 +1,136 @@
import "reflect-metadata";
import type { PoolClient } from "pg";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { appendTenantAudit } from "../audit/audit.service.js";
import { parseContact, parsePatch } from "./outreach.controller.js";
import { OutreachService } from "./outreach.service.js";
/**
* Outreach service + request parsing with the DB faked. RLS and the migration
* shape belong to a live-DB suite.
*/
const dbMock = vi.hoisted(() => ({ withTenant: vi.fn() }));
vi.mock("../db/client.js", () => ({ withTenant: dbMock.withTenant }));
vi.mock("../audit/audit.service.js", () => ({ appendTenantAudit: vi.fn(async () => "audit-id") }));
const WS = "0d9a1b40-6f0f-4b1e-8f0e-3f3c9e6b4a10";
const ID = "11111111-2222-4333-8444-555555555555";
const ACTOR = { userId: "8f7f6e50-7b0f-4a2e-9d3e-2f2d1a0c5b21", role: "Editor" };
const row = (overrides: Record<string, unknown> = {}) => ({
id: ID,
name: "Climáximo",
platform: "bluesky",
handle: "climaximopt.bsky.social",
profile_url: "https://bsky.app/profile/climaximopt.bsky.social",
category: "clima",
language: "pt",
message: "Olá!",
notes: "",
status: "todo",
sent_at: null,
replied_at: null,
created_at: new Date("2026-10-15T10:00:00Z"),
updated_at: new Date("2026-10-15T10:00:00Z"),
...overrides,
});
type Query = (sql: string, values?: unknown[]) => Promise<{ rows: unknown[]; rowCount?: number }>;
function tenant(query: Query): Array<{ sql: string; values: unknown[] }> {
const calls: Array<{ sql: string; values: unknown[] }> = [];
dbMock.withTenant.mockImplementation(async (_ws: string, fn: (client: PoolClient) => Promise<unknown>) =>
fn({
query: async (sql: string, values: unknown[] = []) => {
calls.push({ sql, values });
return query(sql, values);
},
} as unknown as PoolClient),
);
return calls;
}
beforeEach(() => {
dbMock.withTenant.mockReset();
vi.mocked(appendTenantAudit).mockClear();
});
describe("parseContact", () => {
const valid = { name: " DAL ", platform: "Instagram-x", handle: "@federationdal", profileUrl: "https://example.org/federationdal", language: "fr" };
it("trims and lowercases the platform id", () => {
expect(parseContact(valid, "c")).toEqual({ name: "DAL", platform: "instagram-x", handle: "@federationdal", profileUrl: "https://example.org/federationdal", language: "fr" });
});
it("rejects non-https profile links and odd platform ids", () => {
expect(() => parseContact({ ...valid, profileUrl: "http://example.org/x" }, "c")).toThrow(/https/);
expect(() => parseContact({ ...valid, profileUrl: "javascript:alert(1)" }, "c")).toThrow(/https/);
expect(() => parseContact({ ...valid, platform: "x y" }, "c")).toThrow(/platform/);
expect(() => parseContact({ ...valid, language: "spanish" }, "c")).toThrow(/language/);
expect(() => parseContact({ ...valid, name: " " }, "c")).toThrow(/name/);
});
});
describe("parsePatch", () => {
it("accepts the known statuses only and refuses an empty patch", () => {
expect(parsePatch({ status: "sent" })).toEqual({ status: "sent" });
expect(() => parsePatch({ status: "spam" })).toThrow(/status/);
expect(() => parsePatch({})).toThrow(/nothing/);
});
});
describe("OutreachService", () => {
const service = new OutreachService();
it("imports in one statement, skips known accounts and audits counts only", async () => {
const calls = tenant(async () => ({ rows: [{ id: ID }], rowCount: 1 }));
const result = await service.import(WS, [
{ name: "A", platform: "bluesky", handle: "a", profileUrl: "https://bsky.app/profile/a", message: "private draft" },
{ name: "B", platform: "bluesky", handle: "b", profileUrl: "https://bsky.app/profile/b" },
], ACTOR);
expect(result).toEqual({ added: 1, skipped: 1 });
expect(calls).toHaveLength(1);
expect(calls[0]?.sql).toContain("ON CONFLICT (workspace_id, platform, lower(handle)) DO NOTHING");
expect(JSON.parse(String(calls[0]?.values[2]))).toHaveLength(2);
expect(appendTenantAudit).toHaveBeenCalledWith(WS, expect.objectContaining({ action: "outreach.imported", diff: { added: 1, skipped: 1 } }));
expect(JSON.stringify(vi.mocked(appendTenantAudit).mock.calls)).not.toContain("private draft");
});
it("does not audit an import that added nothing", async () => {
tenant(async () => ({ rows: [], rowCount: 0 }));
expect(await service.import(WS, [{ name: "A", platform: "bluesky", handle: "a", profileUrl: "https://bsky.app/profile/a" }], ACTOR)).toEqual({ added: 0, skipped: 1 });
expect(appendTenantAudit).not.toHaveBeenCalled();
});
it("marks a contact sent and audits the transition without the message", async () => {
const calls = tenant(async (sql) => {
if (sql.startsWith("SELECT status")) return { rows: [{ status: "todo" }] };
if (sql.startsWith("UPDATE outreach_contacts")) return { rows: [row({ status: "sent", message: "Olá, secret text", sent_at: new Date("2026-10-15T11:00:00Z") })] };
throw new Error(`unexpected query: ${sql}`);
});
const contact = await service.update(WS, ID, { status: "sent", message: "Olá, secret text" }, ACTOR);
expect(contact.status).toBe("sent");
expect(contact.sentAt).toBe("2026-10-15T11:00:00.000Z");
expect(calls[1]?.values).toEqual([ID, "Olá, secret text", null, "sent", true, ACTOR.userId]);
expect(appendTenantAudit).toHaveBeenCalledWith(WS, expect.objectContaining({ action: "outreach.sent", diff: expect.objectContaining({ from: "todo", to: "sent" }) }));
expect(JSON.stringify(vi.mocked(appendTenantAudit).mock.calls)).not.toContain("secret text");
});
it("edits the draft without a status change and without an audit entry", async () => {
const calls = tenant(async (sql) => {
if (sql.startsWith("SELECT status")) return { rows: [{ status: "sent" }] };
return { rows: [row({ status: "sent", message: "v2" })] };
});
await service.update(WS, ID, { message: "v2" }, ACTOR);
expect(calls[1]?.values).toEqual([ID, "v2", null, "sent", false, ACTOR.userId]);
expect(appendTenantAudit).not.toHaveBeenCalled();
});
it("404s on an unknown contact", async () => {
tenant(async () => ({ rows: [] }));
await expect(service.update(WS, ID, { status: "sent" }, ACTOR)).rejects.toThrow(/not found/);
await expect(service.remove(WS, ID, ACTOR)).rejects.toThrow(/not found/);
expect(appendTenantAudit).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,61 @@
# Run in native Windows PowerShell as the user who will transcode on moxexleyhp1.
[CmdletBinding()]
param(
[string]$JobRoot = 'C:\transcode-jobs',
[string]$PythonExe
)
$ErrorActionPreference = 'Stop'
if ($env:OS -ne 'Windows_NT') {
throw 'Run this installer on Windows directly, not inside WSL.'
}
$WorkerScript = (Resolve-Path (Join-Path $PSScriptRoot 'windows-transcode-worker.py')).Path
if (-not $PythonExe) {
$PythonCommand = Get-Command python.exe -ErrorAction SilentlyContinue
if (-not $PythonCommand) {
$PythonCommand = Get-Command py.exe -ErrorAction SilentlyContinue
}
if (-not $PythonCommand) {
throw 'Install Python 3.9+ for Windows, then rerun with -PythonExe C:\path\to\python.exe.'
}
$PythonExe = $PythonCommand.Source
}
# Resolve py.exe (if used) to the real interpreter for the scheduled task.
$ResolvedPython = & $PythonExe -c 'import sys; assert sys.version_info >= (3, 9); print(sys.executable)'
if ($LASTEXITCODE -ne 0 -or -not $ResolvedPython) {
throw 'Could not run Python 3.9+. Pass -PythonExe with a working Windows interpreter.'
}
$PythonExe = ($ResolvedPython | Select-Object -Last 1).Trim()
$FfmpegExe = (Get-Command ffmpeg.exe -ErrorAction Stop).Source
$JobRoot = [System.IO.Path]::GetFullPath($JobRoot)
$Inbox = Join-Path $JobRoot 'inbox'
foreach ($Name in @('inbox', 'done', 'failed')) {
New-Item -ItemType Directory -Force -Path (Join-Path $JobRoot $Name) | Out-Null
}
$TaskName = 'TMBH-TranscodeWorker'
$UserId = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
$Arguments = '-u "{0}" --inbox "{1}" --ffmpeg "{2}"' -f $WorkerScript, $Inbox, $FfmpegExe
$Action = New-ScheduledTaskAction -Execute $PythonExe -Argument $Arguments -WorkingDirectory $PSScriptRoot
$AtLogon = New-ScheduledTaskTrigger -AtLogOn -User $UserId
# No repetition duration means this trigger repeats indefinitely.
$EveryFiveMinutes = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) -RepetitionInterval (New-TimeSpan -Minutes 5)
$Principal = New-ScheduledTaskPrincipal -UserId $UserId -LogonType Interactive -RunLevel Limited
$Settings = New-ScheduledTaskSettingsSet -MultipleInstances IgnoreNew -ExecutionTimeLimit ([TimeSpan]::Zero) -StartWhenAvailable -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries
Register-ScheduledTask -TaskName $TaskName -Action $Action -Trigger @($AtLogon, $EveryFiveMinutes) -Principal $Principal -Settings $Settings -Description 'Poll JSON jobs and transcode with native Windows NVENC or Intel QSV.' -Force | Out-Null
Start-ScheduledTask -TaskName $TaskName
Write-Host "Installed and started: $TaskName (logon + every 5 minutes; duplicate starts ignored)."
Write-Host "Python: $PythonExe"
Write-Host "FFmpeg: $FfmpegExe"
Write-Host "Worker: $WorkerScript"
Write-Host "Inbox: $Inbox"
Write-Host "Done: $(Join-Path $JobRoot 'done'); failed: $(Join-Path $JobRoot 'failed') with <job>.json.err diagnostics."
Write-Host 'Keep this Windows account logged on; the task uses its interactive GPU session.'
Write-Host 'Keep the repository at this location, or rerun this installer after moving it.'
Write-Host 'Publish jobs by writing a .tmp file, closing it, then renaming it to .json.'
Write-Host "Status: Get-ScheduledTask -TaskName '$TaskName'"
Write-Host "Stop: Stop-ScheduledTask -TaskName '$TaskName'"
Write-Host "Manual tests: $(Join-Path $PSScriptRoot 'test-windows-worker.md')"

View file

@ -0,0 +1,116 @@
# Native Windows transcode worker: manual checks on moxexleyhp1
Run these commands in **Windows PowerShell**, using Windows Python 3.9+ and
the existing WinGet ffmpeg on PATH. Keep this Windows account logged on.
## Install
1. Open PowerShell in the Windows checkout of this repository.
2. Run:
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\apps\media-worker\scripts\install-windows-worker.ps1
Get-ScheduledTask -TaskName 'TMBH-TranscodeWorker'
```
If Python is not found, add `-PythonExe 'C:\path\to\python.exe'` to the
installer command. The optional `-JobRoot` changes all three queue folders.
The task starts immediately, polls every 5 seconds, starts at logon, and
is triggered every 5 minutes to recover if it exits. Duplicate starts are
ignored; a per-inbox file lock also prevents competing manual workers.
The installer can be rerun to update the task.
3. Verify `inbox`, `done`, and `failed` exist under `C:\transcode-jobs`.
Generate a short input with video and audio:
```powershell
ffmpeg -hide_banner -n -f lavfi -i 'testsrc2=size=1280x720:rate=30' -f lavfi -i 'sine=frequency=440:sample_rate=48000' -t 10 -c:v mpeg4 -q:v 3 -c:a aac 'C:\transcode-jobs\sample.mp4'
if ($LASTEXITCODE -ne 0) { throw 'Sample generation failed' }
```
Use fresh output/job names when repeating these checks. Existing output
files are rejected and preserved. Relative input/output paths resolve
against the inbox; absolute Windows paths are recommended. Outputs use
their extension to choose the container. Jobs run sequentially.
## NVENC H.264 on the RTX 3090
- [ ] Publish a job atomically, so polling never reads a partly written JSON:
```powershell
@{ input = 'C:\transcode-jobs\sample.mp4'; output = 'C:\transcode-jobs\nvenc.mp4'; preset = 'nvenc_h264'; crf = 23 } | ConvertTo-Json | Set-Content -Encoding UTF8 'C:\transcode-jobs\inbox\nvenc.tmp'
Move-Item 'C:\transcode-jobs\inbox\nvenc.tmp' 'C:\transcode-jobs\inbox\nvenc.json'
```
- [ ] Within a poll interval plus encode time, `done\nvenc.json` and a
playable, nonempty `C:\transcode-jobs\nvenc.mp4` exist; the inbox job is gone.
- [ ] Inspect the output:
```powershell
ffprobe -v error -show_entries stream=codec_name,codec_type -of json 'C:\transcode-jobs\nvenc.mp4'
```
Expect H.264 video and AAC audio. While encoding, observe NVIDIA's **Video
Encode** graph in Task Manager (use a longer input if needed).
- [ ] Repeat with `preset = 'nvenc_hevc'`, a new job name, and output
`nvenc-hevc.mp4`; expect HEVC video and AAC audio. If the installed ffmpeg
lacks `hevc_nvenc`, expect a failed job with encoder diagnostics instead.
## Intel QuickSync H.264
- [ ] Publish a QSV job, omitting `crf` to exercise the default quality of 23:
```powershell
@{ input = 'C:\transcode-jobs\sample.mp4'; output = 'C:\transcode-jobs\qsv.mp4'; preset = 'qsv_h264' } | ConvertTo-Json | Set-Content -Encoding UTF8 'C:\transcode-jobs\inbox\qsv.tmp'
Move-Item 'C:\transcode-jobs\inbox\qsv.tmp' 'C:\transcode-jobs\inbox\qsv.json'
```
- [ ] Verify `done\qsv.json`, playable `qsv.mp4`, and H.264/AAC streams using
the ffprobe command above with the QSV output path.
- [ ] Observe the Intel GPU's **Video Encode** graph during the job. Encoder
initialization failures belong in `failed` and must include ffmpeg's error;
the worker never retries with a software video encoder.
## Failure and continued polling
- [ ] Queue a missing input:
```powershell
@{ input = 'C:\transcode-jobs\does-not-exist.mp4'; output = 'C:\transcode-jobs\must-not-exist.mp4'; preset = 'nvenc_h264' } | ConvertTo-Json | Set-Content -Encoding UTF8 'C:\transcode-jobs\inbox\missing.tmp'
Move-Item 'C:\transcode-jobs\inbox\missing.tmp' 'C:\transcode-jobs\inbox\missing.json'
```
- [ ] Expect `failed\missing.json` and `failed\missing.json.err`, mentioning
the missing input. No output is created and the inbox job is gone.
- [ ] Queue a text file as input with a valid preset. Expect a failed JSON
and `.json.err` containing ffmpeg's nonzero exit code and stderr. No final
output or `.part.mp4` file should remain.
- [ ] Queue malformed JSON, an unknown preset, and a string `crf` in separate
jobs. Each must move to `failed` with a useful `.err` file.
- [ ] Queue an existing output path (e.g. the sample). Expect failure without
changing the existing file.
- [ ] Queue another valid job with a fresh output name; verify completion
after the failed jobs. Reuse a failed job filename and verify both archived
jobs and their errors survive (a UUID is added on name collisions).
## Task lifecycle and foreground diagnostics
- [ ] Log off and back on; verify the task runs. Stop the task, then verify
the repeating trigger starts it again within 5 minutes.
- [ ] For foreground diagnostics or a single pass, stop **and disable** the
task first so its repeating trigger cannot race the manual run:
```powershell
Disable-ScheduledTask -TaskName 'TMBH-TranscodeWorker'
Stop-ScheduledTask -TaskName 'TMBH-TranscodeWorker'
python .\apps\media-worker\scripts\windows-transcode-worker.py --once
# Exit status is 1 if any job in this pass failed; otherwise 0.
Enable-ScheduledTask -TaskName 'TMBH-TranscodeWorker'
Start-ScheduledTask -TaskName 'TMBH-TranscodeWorker'
```
Omit `--once` for continuous foreground polling; press Ctrl+C to stop.
A second worker on the same inbox must exit without executing jobs.
Force-stopping during an encode can leave a hidden `.part` output; remove
it after confirming no ffmpeg process is using it. Its job stays in the
inbox for retry. Normal encode failures clean up the temporary output.

View file

@ -0,0 +1,185 @@
#!/usr/bin/env python3
"""Native Windows hardware transcode worker; Python standard library only."""
import argparse
import json
import logging
import math
import os
from pathlib import Path
import subprocess
import tempfile
import time
import uuid
PRESETS = {
"nvenc_h264": ["-c:v", "h264_nvenc", "-preset", "p4", "-rc", "vbr"],
"qsv_h264": ["-c:v", "h264_qsv"],
"nvenc_hevc": ["-c:v", "hevc_nvenc", "-preset", "p4", "-rc", "vbr"],
}
class WorkerLock:
"""Windows releases this byte-range lock even if the worker crashes."""
def __init__(self, inbox):
self.path = inbox / ".worker.lock"
self.file = None
def __enter__(self):
import msvcrt
self.file = self.path.open("a+b")
self.file.seek(0, os.SEEK_END)
if self.file.tell() == 0:
self.file.write(b"\0")
self.file.flush()
self.file.seek(0)
try:
msvcrt.locking(self.file.fileno(), msvcrt.LK_NBLCK, 1)
except OSError:
self.file.close()
self.file = None
raise
return self
def __exit__(self, *args):
# Closing the handle releases the lock; keep the file to avoid races.
self.file.close()
def load_job(job_path):
# Windows PowerShell 5.1's UTF8 output includes a BOM.
with job_path.open(encoding="utf-8-sig") as source:
job = json.load(source)
if not isinstance(job, dict):
raise ValueError("Job must be a JSON object")
for key in ("input", "output", "preset"):
if not isinstance(job.get(key), str) or not job[key].strip():
raise ValueError("Job requires a nonempty string: " + key)
if job["preset"] not in PRESETS:
raise ValueError("Unknown preset: " + job["preset"])
quality = job.get("crf", 23)
if not isinstance(quality, int) or isinstance(quality, bool):
raise ValueError("crf must be an integer")
def job_file(value):
path = Path(value).expanduser()
return (job_path.parent / path).resolve()
input_path = job_file(job["input"])
output_path = job_file(job["output"])
if not input_path.is_file():
raise ValueError("Input file does not exist: " + str(input_path))
if input_path == output_path:
raise ValueError("Input and output must be different files")
if output_path.exists():
raise ValueError("Output already exists: " + str(output_path))
if not output_path.suffix:
raise ValueError("Output needs a container extension, such as .mp4")
return input_path, output_path, job["preset"], quality
def archive_job(job_path, directory, error=None):
target = directory / job_path.name
while target.exists() or target.with_suffix(target.suffix + ".err").exists():
target = directory / (job_path.stem + "-" + uuid.uuid4().hex + ".json")
if error is not None:
target.with_suffix(target.suffix + ".err").write_text(error, encoding="utf-8")
job_path.rename(target)
return target
def process_job(job_path, done, failed, ffmpeg):
temporary_output = None
try:
input_path, output_path, preset, quality = load_job(job_path)
output_path.parent.mkdir(parents=True, exist_ok=True)
# Keep the container suffix so ffmpeg can infer the output format.
temporary_output = output_path.with_name(
"." + output_path.stem + "-" + uuid.uuid4().hex + ".part" + output_path.suffix
)
video_args = list(PRESETS[preset])
if preset == "qsv_h264":
video_args += ["-global_quality", str(quality)]
else:
video_args += ["-cq", str(quality), "-b:v", "0"]
command = [
ffmpeg, "-hide_banner", "-nostdin", "-n", "-i", str(input_path),
"-map", "0:v:0", "-map", "0:a:0?",
*video_args, "-pix_fmt", "nv12", "-c:a", "aac", "-b:a", "192k",
str(temporary_output),
]
logging.info("Job %s: %s", job_path.name, subprocess.list2cmdline(command))
with tempfile.TemporaryFile() as stderr:
result = subprocess.run(command, stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL, stderr=stderr, check=False)
if result.returncode != 0:
# Bound diagnostic size even for a long-running failed encode.
stderr.seek(0, os.SEEK_END)
stderr.seek(max(0, stderr.tell() - 65536))
detail = stderr.read().decode("utf-8", errors="replace")
raise RuntimeError(
"ffmpeg exited with code {}\nCommand: {}\n\n{}".format(
result.returncode, subprocess.list2cmdline(command), detail
)
)
if not temporary_output.is_file() or temporary_output.stat().st_size == 0:
raise RuntimeError("ffmpeg returned success without a nonempty output")
# On Windows rename refuses to replace an existing destination.
temporary_output.rename(output_path)
archive_job(job_path, done)
logging.info("Completed %s -> %s", job_path.name, output_path)
return True
except Exception as error:
logging.error("Failed %s: %s", job_path.name, error)
archive_job(job_path, failed, "{}: {}\n".format(type(error).__name__, error))
return False
finally:
if temporary_output is not None:
temporary_output.unlink(missing_ok=True)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--inbox", type=Path, default=Path(r"C:\transcode-jobs\inbox"),
help="JSON inbox; done/failed are sibling directories")
parser.add_argument("--ffmpeg", default="ffmpeg", help="ffmpeg executable (default: PATH)")
parser.add_argument("--poll-interval", type=float, default=5, help="Poll interval in seconds")
parser.add_argument("--once", action="store_true", help="Process the current inbox and exit")
args = parser.parse_args()
if os.name != "nt":
parser.error("Run this worker with Windows Python directly, not inside WSL")
if not math.isfinite(args.poll_interval) or args.poll_interval <= 0:
parser.error("--poll-interval must be a finite positive number")
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
inbox = args.inbox.resolve()
done, failed = inbox.parent / "done", inbox.parent / "failed"
for directory in (inbox, done, failed):
directory.mkdir(parents=True, exist_ok=True)
lock = WorkerLock(inbox)
try:
lock.__enter__()
except OSError as error:
logging.error("Cannot acquire worker lock (another worker may be running): %s", error)
return 1
try:
logging.info("Polling %s", inbox)
had_failure = False
while True:
for job_path in sorted(inbox.glob("*.json")):
if job_path.is_file():
had_failure = not process_job(job_path, done, failed, args.ffmpeg) or had_failure
if args.once:
return int(had_failure)
time.sleep(args.poll_interval)
except KeyboardInterrupt:
logging.info("Worker stopped")
return 0
finally:
lock.__exit__()
if __name__ == "__main__":
raise SystemExit(main())

View file

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

View file

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

View file

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

View file

@ -16,7 +16,7 @@ import { useIsMobile } from "../../hooks/use-is-mobile";
const titles: Record<string, string> = {
streams: "Home", search: "Search", inbox: "Inbox", approvals: "Approvals", more: "More", compose: "New post",
calendar: "Calendar", video: "Video", analytics: "Analytics", connections: "Connections", team: "Team",
audit: "Audit log", onboarding: "Welcome", settings: "Settings", archive: "Archive", files: "Files", chat: "Chat",
audit: "Audit log", onboarding: "Welcome", settings: "Settings", archive: "Archive", files: "Files", chat: "Chat", outreach: "Outreach",
};
const rootTabs = new Set(["/streams", "/search", "/inbox", "/more"]);
const scrollPositions = new Map<string, { main: number; nested: number[] }>();

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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