// Artifacts — direction C, PR 5 (ScaleArtifacts.dc.html). // Every file and page shared in your pods, newest first, from one query: // GET /api/artifacts. The inspector's Files pane is the same query with // podId fixed; this page is the query with podId as a filter. import React, { useEffect, useMemo, useState } from 'react'; import { Link, useSearchParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import axios from 'axios'; import { getSignedAttachmentUrl } from '../../utils/signedAttachmentUrl'; import V2Lightbox from './V2Lightbox'; import V2PageHead from './V2PageHead'; export type ArtifactKind = 'image' | 'page' | 'doc'; export interface ArtifactItem { id: string; fileName: string; name: string; contentType: string; kind: ArtifactKind; size: number | null; podId: string | null; podName: string | null; sharedBy: { id: string | null; username: string | null; displayName: string | null }; createdAt: string | null; } interface ArtifactsResponse { items: ArtifactItem[]; nextCursor: string | null; total: number; limit: number } interface PodRef { _id: string; name: string; createdAt?: string } const KINDS: Array<{ key: '' | ArtifactKind; label: string }> = [ { key: '', label: 'all' }, { key: 'image', label: 'images' }, { key: 'doc', label: 'docs' }, { key: 'page', label: 'pages' }, ]; // The ext chip: the file's own extension, uppercase mono, bordered — never a colour square. export const extOf = (name: string | null | undefined): string => { const m = /\.([a-z0-9]{1,5})$/i.exec(String(name || '')); return m ? m[1].toUpperCase() : 'FILE'; }; export const formatSize = (bytes: number | null | undefined): string => { if (typeof bytes !== 'number' || !Number.isFinite(bytes) || bytes <= 0) return '—'; const units = ['B', 'KB', 'MB', 'GB']; let v = bytes; let i = 0; while (v >= 1024 && i < units.length - 1) { v /= 1024; i += 1; } return `${v.toFixed(v < 10 && i > 0 ? 1 : 0)} ${units[i]}`; }; // Same vocabulary as Activity: now / 32m / 6h / 2d — and mo past 30 days. export const whenLabel = (value: string | null | undefined): string => { if (!value) return ''; const elapsed = Math.max(0, Date.now() - new Date(value).getTime()); const minutes = Math.floor(elapsed / 60_000); if (minutes < 1) return 'now'; if (minutes < 60) return `${minutes}m`; const hours = Math.floor(minutes / 60); if (hours < 24) return `${hours}h`; const days = Math.floor(hours / 24); if (days < 30) return `${days}d`; return `${Math.floor(days / 30)}mo`; }; // Pods carry a long descriptive title; the table names them by the short leading name. export const shortPodName = (name: string | null | undefined): string => ( (name || '').trim().split(/\s*[·—–:|]\s*/)[0].trim() || (name || '').trim() ); const sharedByLabel = (item: ArtifactItem): string => item.sharedBy.displayName || item.sharedBy.username || '—'; const V2ArtifactsPage: React.FC = () => { const { t } = useTranslation(); const [searchParams, setSearchParams] = useSearchParams(); const podId = searchParams.get('podId') || ''; const kind = (searchParams.get('kind') || '') as '' | ArtifactKind; const [q, setQ] = useState(searchParams.get('q') || ''); const [pods, setPods] = useState([]); const [items, setItems] = useState([]); const [total, setTotal] = useState(0); const [nextCursor, setNextCursor] = useState(null); const [loading, setLoading] = useState(true); const [loadingMore, setLoadingMore] = useState(false); const [error, setError] = useState(null); const [podMenuOpen, setPodMenuOpen] = useState(false); // ux-lead 66462: an image row opens the chat lightbox; pages and docs open in a new tab. const [lightbox, setLightbox] = useState(null); const setParam = (key: string, value: string) => { const next = new URLSearchParams(searchParams); if (value) next.set(key, value); else next.delete(key); setSearchParams(next, { replace: true }); }; const headers = useMemo(() => { const token = localStorage.getItem('token'); return token ? { Authorization: `Bearer ${token}` } : undefined; }, []); useEffect(() => { let active = true; axios.get('/api/pods', { headers }) .then((res) => { if (active) setPods(Array.isArray(res.data) ? res.data : []); }) .catch(() => { if (active) setPods([]); }); return () => { active = false; }; }, [headers]); // Debounce the search into the URL so a shared link carries the query. useEffect(() => { const handle = globalThis.window.setTimeout(() => { if ((searchParams.get('q') || '') !== q) setParam('q', q); }, 250); return () => globalThis.window.clearTimeout(handle); // eslint-disable-next-line react-hooks/exhaustive-deps }, [q]); const urlQ = searchParams.get('q') || ''; useEffect(() => { let active = true; setLoading(true); setError(null); const params: Record = { limit: 50 }; if (podId) params.podId = podId; if (kind) params.kind = kind; if (urlQ) params.q = urlQ; axios.get('/api/artifacts', { headers, params }) .then((res) => { if (!active) return; setItems(res.data.items || []); setTotal(typeof res.data.total === 'number' ? res.data.total : (res.data.items || []).length); setNextCursor(res.data.nextCursor || null); }) .catch(() => { if (active) { setError(t('artifacts.loadFailed')); setItems([]); setTotal(0); setNextCursor(null); } }) .finally(() => { if (active) setLoading(false); }); return () => { active = false; }; }, [headers, podId, kind, urlQ, t]); const loadMore = async () => { if (!nextCursor || loadingMore) return; setLoadingMore(true); try { const params: Record = { limit: 50, after: nextCursor }; if (podId) params.podId = podId; if (kind) params.kind = kind; if (urlQ) params.q = urlQ; const res = await axios.get('/api/artifacts', { headers, params }); setItems((current) => { const seen = new Set(current.map((item) => item.id)); return [...current, ...(res.data.items || []).filter((item) => !seen.has(item.id))]; }); setNextCursor(res.data.nextCursor || null); } catch { setError(t('artifacts.loadFailed')); } finally { setLoadingMore(false); } }; // Day zero (d215 s-artifacts): nothing shared and nothing filtered, so there is nothing to // filter — no controls, no count, one card. A filtered empty result keeps the controls. const dayZero = !loading && !error && total === 0 && items.length === 0 && !urlQ && !kind && !podId; // The card's key opens the account's oldest pod: signup creates My Workspace first. /api/pods // lists by last update, so pick by createdAt rather than position. const workspacePod = useMemo(() => { const createdMs = (pod: PodRef) => (pod.createdAt ? Date.parse(pod.createdAt) : Number.POSITIVE_INFINITY); return pods.reduce((oldest, pod) => (!oldest || createdMs(pod) < createdMs(oldest) ? pod : oldest), null); }, [pods]); const openItem = async (item: ArtifactItem) => { if (item.kind === 'image') { setLightbox(item); return; } const url = await getSignedAttachmentUrl(`/api/uploads/${item.fileName}`); if (url) globalThis.window.open(url, '_blank', 'noopener'); }; const scopedPods = pods; const selectedPod = scopedPods.find((pod) => pod._id === podId) || null; return (
{!dayZero && (
{KINDS.map((option) => ( ))}
{ if (event.key === 'Escape') setPodMenuOpen(false); }}> {scopedPods.slice(0, 2).map((pod) => ( ))} {scopedPods.length > 2 && ( )} {podMenuOpen && (
{scopedPods.slice(2).map((pod) => ( ))}
)}
)} {error &&
{error}
} {loading && !error &&
} {dayZero && (

{t('artifacts.dayZero.title')}

{t('artifacts.dayZero.body')}

{workspacePod && {t('artifacts.dayZero.open', { pod: workspacePod.name })}}
)} {!loading && !error && items.length === 0 && (urlQ || kind || podId) && (
{t('artifacts.emptyFiltered')}
)} {!loading && items.length > 0 && (
{items.map((item) => ( ))}
{t('artifacts.columns.file')} {t('artifacts.columns.kind')} {t('artifacts.columns.pod')} {t('artifacts.columns.sharedBy')} {t('artifacts.columns.when')} {t('artifacts.columns.size')}
{/* ≤760 (66462 miss 2): the pod folds under the name as a mono line; the pod column hides. */} {shortPodName(item.podName)}
{t(`artifacts.kindNames.${item.kind}`)} {shortPodName(item.podName)} {sharedByLabel(item)} {whenLabel(item.createdAt)} {item.kind === 'page' ? '—' : formatSize(item.size)}
)} {lightbox && setLightbox(null)} />} {!loading && items.length > 0 && (
{t('artifacts.foot', { shown: items.length, total })} {nextCursor && }
)}
); }; export default V2ArtifactsPage;