/** * DocumentsListPage — documents2-style library: sidebar, folders, favorites, grid/list. * New documents default to “Untitled Document”; rename in editor (no modal). */ import { useMemo, useState, useCallback, useEffect, useRef } from 'react' import { useNavigate } from 'react-router-dom' import type { RecordData } from 'deepspace' import { useUser } from 'deepspace' import { useQuery } from 'deepspace' import { useMutations } from 'deepspace' import { ROLES, type Role } from 'deepspace' import { FileText, Plus, Trash2, LayoutGrid, LayoutList, SortAsc, Search, Star, Folder, ChevronRight, MoreVertical, Pencil, } from 'lucide-react' import { LibrarySidebar, readSidebarCollapsed, writeSidebarCollapsed } from './LibrarySidebar' import { parseDocumentsIdList, recordsReadyForMutation, type DocFolderFields, type DocumentsDocumentFields, type LibraryNavSelection, } from './documents-library-types' import './documents-ui.css' type SortOption = 'lastEdited' | 'titleAZ' | 'titleZA' type ViewMode = 'grid' | 'list' const SORT_OPTIONS: { value: SortOption; label: string }[] = [ { value: 'lastEdited', label: 'Last edited' }, { value: 'titleAZ', label: 'Title A–Z' }, { value: 'titleZA', label: 'Title Z–A' }, ] const UNTITLED = 'Untitled Document' const FAVORITES_KEY = 'deepspace-documents-favorites' function readFavorites(): Set { try { const stored = localStorage.getItem(FAVORITES_KEY) return new Set(parseDocumentsIdList(stored ?? undefined)) } catch { return new Set() } } function writeFavorites(favorites: Set): void { try { localStorage.setItem(FAVORITES_KEY, JSON.stringify([...favorites])) } catch { // Favorites are local convenience state; storage denial must not break UI state. } } function greetingForTime(): string { const h = new Date().getHours() if (h < 12) return 'Good morning' if (h < 17) return 'Good afternoon' return 'Good evening' } function formatDate(iso: string): string { return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric', }) } export default function DocumentsListPage() { const { user } = useUser() const navigate = useNavigate() const userRole = (user?.role ?? ROLES.VIEWER) as Role const roleCanCreate = userRole === ROLES.MEMBER || userRole === ROLES.ADMIN const [searchQuery, setSearchQuery] = useState('') const [sortBy, setSortBy] = useState('lastEdited') const [viewMode, setViewMode] = useState('grid') const [sortOpen, setSortOpen] = useState(false) const [favorites, setFavorites] = useState>(readFavorites) const [libraryNav, setLibraryNav] = useState({ kind: 'all' }) const [sidebarCollapsed, setSidebarCollapsed] = useState(readSidebarCollapsed) const [renamingId, setRenamingId] = useState(null) const [renameValue, setRenameValue] = useState('') const [folderRenamingId, setFolderRenamingId] = useState(null) const [folderRenameValue, setFolderRenameValue] = useState('') const [moveMenuId, setMoveMenuId] = useState(null) const [actionsMenuId, setActionsMenuId] = useState(null) const { records: documents, status } = useQuery('documents', { orderBy: 'createdAt', orderDir: 'desc', }) const { createConfirmed, remove, put, ready: documentsMutationsReady, } = useMutations('documents') const { records: folderRecords, status: foldersStatus } = useQuery( 'doc_folders', { orderBy: 'createdAt', orderDir: 'asc', }, ) const { create: createFolder, put: putFolder, remove: removeFolder, ready: folderMutationsReady, } = useMutations('doc_folders') const documentWritesReady = recordsReadyForMutation(status, documentsMutationsReady) const folderWritesReady = recordsReadyForMutation(foldersStatus, folderMutationsReady) const canCreate = roleCanCreate && documentWritesReady const canManageFolders = canCreate && folderWritesReady const canMoveDocuments = canCreate && foldersStatus === 'ready' const myFolders = useMemo( () => (folderRecords ?? []).filter((f) => f.data.ownerId === user?.id), [folderRecords, user?.id], ) const sortedFolders = useMemo( () => [...myFolders].sort((a, b) => (a.data.name ?? '').localeCompare(b.data.name ?? '')), [myFolders], ) useEffect(() => { if (libraryNav.kind !== 'folder') return if (!sortedFolders.some((f) => f.recordId === libraryNav.folderId)) { setLibraryNav({ kind: 'all' }) } }, [libraryNav, sortedFolders]) const displayFirstName = user?.name?.trim().split(/\s+/)[0] ?? 'there' const isOwnedDocument = useCallback( (d: RecordData) => d.data.ownerId === user?.id, [user?.id], ) const isSharedWithCurrentUser = useCallback( (d: RecordData) => Boolean( user?.id && d.data.ownerId !== user.id && parseDocumentsIdList(d.data.collaborators).includes(user.id), ), [user?.id], ) const ownedDocumentCount = useMemo( () => (documents ?? []).filter(isOwnedDocument).length, [documents, isOwnedDocument], ) const listHeading = useMemo(() => { if (libraryNav.kind === 'shared') return 'Shared with me' if (libraryNav.kind === 'favorites') return 'Favorites' if (libraryNav.kind === 'uncategorized') return 'Uncategorized' if (libraryNav.kind === 'folder') { return ( sortedFolders.find((f) => f.recordId === libraryNav.folderId)?.data.name?.trim() || 'Folder' ) } return 'My Documents' }, [libraryNav, sortedFolders]) const matchesNav = useCallback( (d: RecordData) => { const fid = d.data.folderId ?? '' if (libraryNav.kind === 'all') return isOwnedDocument(d) if (libraryNav.kind === 'shared') return isSharedWithCurrentUser(d) if (libraryNav.kind === 'favorites') return favorites.has(d.recordId) if (libraryNav.kind === 'uncategorized') return isOwnedDocument(d) && fid === '' if (libraryNav.kind === 'folder') return isOwnedDocument(d) && fid === libraryNav.folderId return true }, [favorites, isOwnedDocument, isSharedWithCurrentUser, libraryNav], ) const filteredSorted = useMemo(() => { let rows = (documents ?? []).filter(matchesNav) const q = searchQuery.trim().toLowerCase() if (q) rows = rows.filter((d) => d.data.title.toLowerCase().includes(q)) switch (sortBy) { case 'lastEdited': rows.sort((a, b) => b.createdAt.localeCompare(a.createdAt)) break case 'titleAZ': rows.sort((a, b) => a.data.title.localeCompare(b.data.title)) break case 'titleZA': rows.sort((a, b) => b.data.title.localeCompare(a.data.title)) break default: break } return rows }, [documents, matchesNav, searchQuery, sortBy]) const toggleSidebar = useCallback(() => { setSidebarCollapsed((c) => { const n = !c writeSidebarCollapsed(n) return n }) }, []) const toggleFavorite = useCallback((contentId: string) => { setFavorites((prev) => { const next = new Set(prev) if (next.has(contentId)) next.delete(contentId) else next.add(contentId) writeFavorites(next) return next }) }, []) const folderIdForNew = libraryNav.kind === 'folder' ? libraryNav.folderId : '' const handleNewDocument = useCallback(async () => { if (!user || !canCreate) return const id = await createConfirmed({ title: UNTITLED, ownerId: user.id, collaborators: '[]', editors: '[]', folderId: folderIdForNew, }) navigate(`/documents/${id}`) }, [canCreate, createConfirmed, folderIdForNew, navigate, user]) const handleDelete = useCallback( async (docId: string) => { if (!canCreate) return if (confirm('Delete this document?')) await remove(docId) }, [canCreate, remove], ) const handleRenameCommit = useCallback( async (docId: string) => { if (!canCreate) { setRenamingId(null) return } const rec = documents.find((d) => d.recordId === docId) const t = renameValue.trim() setRenamingId(null) if (!rec || !t || t === rec.data.title) return await put(docId, { ...rec.data, title: t }).catch(() => {}) }, [canCreate, documents, put, renameValue], ) const handleCreateFolder = useCallback( async (name: string) => { if (!user || !canManageFolders) return await createFolder({ name, ownerId: user.id }) }, [canManageFolders, createFolder, user], ) const handleDeleteFolder = useCallback( async (folderId: string) => { if (!canManageFolders) return const owned = (documents ?? []).filter( (d) => d.data.ownerId === user?.id && (d.data.folderId ?? '') === folderId, ) await Promise.all(owned.map((d) => put(d.recordId, { ...d.data, folderId: '' }))) await removeFolder(folderId) setFolderRenamingId((id) => (id === folderId ? null : id)) setLibraryNav((nav) => nav.kind === 'folder' && nav.folderId === folderId ? { kind: 'all' } : nav, ) }, [canManageFolders, documents, put, removeFolder, user?.id], ) const commitRenameFolderRef = useRef<() => Promise>(async () => {}) const commitRenameFolder = useCallback(async () => { const id = folderRenamingId const trimmed = folderRenameValue.trim() setFolderRenamingId(null) if (!canManageFolders || !id || !trimmed) return const folder = sortedFolders.find((f) => f.recordId === id) if (!folder || trimmed === (folder.data.name ?? '').trim()) return await putFolder(id, { ...folder.data, name: trimmed }).catch(() => {}) }, [canManageFolders, folderRenameValue, folderRenamingId, putFolder, sortedFolders]) commitRenameFolderRef.current = commitRenameFolder const handleMoveDoc = useCallback( async (docId: string, folderId: string) => { if (!canMoveDocuments) return const rec = documents.find((d) => d.recordId === docId) if (!rec) return await put(docId, { ...rec.data, folderId }) setMoveMenuId(null) }, [canMoveDocuments, documents, put], ) const isLoading = status === 'loading' const accentBtn = 'inline-flex h-9 items-center gap-2 rounded-lg px-4 text-[12px] font-bold text-white shadow-sm transition-opacity hover:opacity-90' const ghostBtn = 'inline-flex h-9 items-center gap-2 rounded-lg border px-4 text-[12px] font-semibold shadow-sm transition-colors' const blankWhen = canCreate && libraryNav.kind !== 'favorites' && (libraryNav.kind === 'all' || libraryNav.kind === 'uncategorized' || libraryNav.kind === 'folder') return (
{user ? ( { setFolderRenamingId(f.recordId) setFolderRenameValue(f.data.name ?? '') }} renamingFolderId={folderRenamingId} renameFolderValue={folderRenameValue} setRenameFolderValue={setFolderRenameValue} onCommitRenameFolder={() => commitRenameFolderRef.current()} onCancelRenameFolder={() => setFolderRenamingId(null)} /> ) : null}

{greetingForTime()}, {displayFirstName}

{ownedDocumentCount} {ownedDocumentCount === 1 ? 'document' : 'documents'} in your documents.

setSearchQuery(e.target.value)} placeholder="Search your library..." className="h-auto w-full rounded-lg py-2 pl-10 pr-10 text-[13px] shadow-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/25" style={{ backgroundColor: 'var(--documents-el-surface)', borderWidth: 1, borderStyle: 'solid', borderColor: 'var(--documents-el-line)', color: 'var(--documents-el-text)', }} /> {searchQuery ? ( ) : null}
{canCreate ? ( ) : null}
{canCreate && sortedFolders.length > 0 && (
{sortedFolders.map((folder) => { const selected = libraryNav.kind === 'folder' && libraryNav.folderId === folder.recordId return ( ) })}
)}

{listHeading}

{sortOpen ? ( <> ))}
) : null}
{isLoading ? (
) : filteredSorted.length === 0 ? (

{searchQuery ? 'No matches' : 'No documents yet'}

{canCreate && !searchQuery ? 'Create your first document with New Document above' : searchQuery ? 'Try a different search' : 'No documents yet'}

) : viewMode === 'grid' ? (
{blankWhen ? ( ) : null} {filteredSorted.map((doc) => { const isOwner = doc.data.ownerId === user?.id const isFav = favorites.has(doc.recordId) const dateLabel = formatDate(doc.createdAt) const fid = doc.data.folderId ?? '' return (
navigate(`/documents/${doc.recordId}`)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() navigate(`/documents/${doc.recordId}`) } }} >
{renamingId === doc.recordId ? ( setRenameValue(e.target.value)} onBlur={() => void handleRenameCommit(doc.recordId)} onKeyDown={(e) => { if (e.key === 'Enter') void handleRenameCommit(doc.recordId) if (e.key === 'Escape') setRenamingId(null) }} onClick={(e) => e.stopPropagation()} className="mb-0.5 w-full rounded-md border px-2 py-1 text-[13px] font-semibold outline-none focus-visible:ring-2 focus-visible:ring-ring/30 disabled:cursor-not-allowed disabled:opacity-50" style={{ borderColor: 'var(--documents-el-line)', backgroundColor: 'var(--documents-el-bg)', color: 'var(--documents-el-text)', }} autoFocus /> ) : (

{doc.data.title}

)}

{dateLabel} {isOwner ? ' · You' : ''} {!isOwner ? ' · Shared with you' : ''} {fid ? ` · ${sortedFolders.find((f) => f.recordId === fid)?.data.name ?? 'Folder'}` : ''}

{isOwner ? (
{canCreate && actionsMenuId === doc.recordId ? ( <> {moveMenuId === doc.recordId ? (
{sortedFolders.map((f) => ( ))}
) : null}
) : null}
) : null}
) })}
) : (
Name
Folder
Modified
{filteredSorted.map((doc) => { const isOwner = doc.data.ownerId === user?.id const isFav = favorites.has(doc.recordId) const dateLabel = formatDate(doc.createdAt) const fid = doc.data.folderId ?? '' const folderLabel = fid ? (sortedFolders.find((f) => f.recordId === fid)?.data.name ?? '—') : '—' return (
navigate(`/documents/${doc.recordId}`)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() navigate(`/documents/${doc.recordId}`) } }} >

{doc.data.title}

{dateLabel} {isOwner ? ' · You' : ' · Shared with you'} {folderLabel !== '—' ? ` · ${folderLabel}` : ''}

{folderLabel}
{dateLabel}
{isOwner ? ( <> ) : null}
) })}
)}
) }