deepspace 0.3.9 → 0.3.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/dist/cli.js +96 -18
  2. package/dist/cli.js.map +1 -1
  3. package/dist/index.d.ts +160 -1
  4. package/dist/index.js +283 -126
  5. package/dist/index.js.map +1 -1
  6. package/dist/server.d.ts +172 -2
  7. package/dist/server.js +581 -1
  8. package/dist/server.js.map +1 -1
  9. package/dist/worker.d.ts +252 -2
  10. package/dist/worker.js +588 -1
  11. package/dist/worker.js.map +1 -1
  12. package/features/admin-page/feature.json +29 -0
  13. package/features/admin-page/src/AdminPage.tsx +292 -0
  14. package/features/admin-page/src/admin-schema.ts +20 -0
  15. package/features/ai-chat/feature.json +41 -0
  16. package/features/ai-chat/src/AiChatPage.tsx +692 -0
  17. package/features/ai-chat/src/ChatPanel.tsx +1098 -0
  18. package/features/ai-chat/src/ai-chat-schema.ts +11 -0
  19. package/features/canvas/feature.json +45 -0
  20. package/features/canvas/src/CanvasEditorPage.tsx +87 -0
  21. package/features/canvas/src/CanvasListPage.tsx +202 -0
  22. package/features/canvas/src/canvas-schema.ts +33 -0
  23. package/features/canvas/src/components/CanvasToolbar.tsx +170 -0
  24. package/features/canvas/src/components/CanvasView.tsx +369 -0
  25. package/features/canvas/src/components/ShapeRenderer.tsx +114 -0
  26. package/features/cron/feature.json +33 -0
  27. package/features/cron/src/CronLogPage.tsx +100 -0
  28. package/features/cron/src/cron.ts +25 -0
  29. package/features/docs/feature.json +96 -0
  30. package/features/docs/src/DocsEditorPage.tsx +1012 -0
  31. package/features/docs/src/DocsListPage.tsx +951 -0
  32. package/features/docs/src/DocsOutlinePanel.tsx +68 -0
  33. package/features/docs/src/DocsPresence.tsx +138 -0
  34. package/features/docs/src/InviteDialog.tsx +336 -0
  35. package/features/docs/src/LibrarySidebar.tsx +464 -0
  36. package/features/docs/src/docs-favorites.ts +18 -0
  37. package/features/docs/src/docs-library-types.ts +15 -0
  38. package/features/docs/src/docs-schema.ts +80 -0
  39. package/features/docs/src/docs-ui.css +309 -0
  40. package/features/docs/src/editor/DocEditorSurface.tsx +257 -0
  41. package/features/docs/src/editor/DocsTiptapToolbar.tsx +560 -0
  42. package/features/docs/src/editor/useDocEditor.ts +206 -0
  43. package/features/docs/src/use-yjs-room-with-awareness.ts +285 -0
  44. package/features/file-attachments/feature.json +42 -0
  45. package/features/file-attachments/src/AttachmentsPage.tsx +864 -0
  46. package/features/file-attachments/src/attachments-constants.ts +9 -0
  47. package/features/file-attachments/src/attachments-schema.ts +45 -0
  48. package/features/file-attachments/src/useFilePreview.ts +471 -0
  49. package/features/file-attachments/src/useMimeTypeDetection.ts +138 -0
  50. package/features/file-manager/feature.json +24 -0
  51. package/features/file-manager/src/FileManagerPage.tsx +330 -0
  52. package/features/integration-test/feature.json +18 -0
  53. package/features/integration-test/src/IntegrationTestPage.tsx +423 -0
  54. package/features/integration-test/src/integration.spec.ts +42 -0
  55. package/features/items/feature.json +33 -0
  56. package/features/items/src/ItemsPage.tsx +325 -0
  57. package/features/items/src/items-constants.ts +10 -0
  58. package/features/items/src/items-schema.ts +27 -0
  59. package/features/kanban/feature.json +18 -0
  60. package/features/kanban/src/Kanban.tsx +283 -0
  61. package/features/landing/feature.json +62 -0
  62. package/features/landing/src/LandingPage.tsx +627 -0
  63. package/features/landing/src/primitives.tsx +389 -0
  64. package/features/landing/src/sections/FAQSection.tsx +110 -0
  65. package/features/landing/src/sections/FeaturesGridSection.tsx +311 -0
  66. package/features/landing/src/sections/HowItWorksSection.tsx +62 -0
  67. package/features/landing/src/sections/LogoCloudSection.tsx +58 -0
  68. package/features/landing/src/sections/ShowcaseSection.tsx +63 -0
  69. package/features/landing/src/sections/StatsSection.tsx +31 -0
  70. package/features/landing/src/sections/TeamSection.tsx +86 -0
  71. package/features/landing/src/sections/TestimonialsSection.tsx +68 -0
  72. package/features/landing/src/sections/VideoSection.tsx +71 -0
  73. package/features/leaderboard/feature.json +33 -0
  74. package/features/leaderboard/src/LeaderboardPage.tsx +372 -0
  75. package/features/leaderboard/src/leaderboard-constants.ts +21 -0
  76. package/features/leaderboard/src/leaderboard-schema.ts +36 -0
  77. package/features/messaging/feature.json +111 -0
  78. package/features/messaging/src/ChatMultiPage.tsx +300 -0
  79. package/features/messaging/src/ChatPage.tsx +75 -0
  80. package/features/messaging/src/components/chat/ChatHeader.tsx +76 -0
  81. package/features/messaging/src/components/chat/MessageActionSheet.tsx +168 -0
  82. package/features/messaging/src/components/chat/MessageInput.tsx +65 -0
  83. package/features/messaging/src/components/chat/MessageItem.tsx +352 -0
  84. package/features/messaging/src/components/chat/MessageList.tsx +248 -0
  85. package/features/messaging/src/components/chat/ReactionPicker.tsx +2 -0
  86. package/features/messaging/src/components/chat/ThreadPanel.tsx +107 -0
  87. package/features/messaging/src/components/chat/UserProfilePopover.tsx +158 -0
  88. package/features/messaging/src/components/chat-multi/AddMemberModal.tsx +188 -0
  89. package/features/messaging/src/components/chat-multi/BrowseGroupsModal.tsx +163 -0
  90. package/features/messaging/src/components/chat-multi/ChannelListItem.tsx +147 -0
  91. package/features/messaging/src/components/chat-multi/ChannelSettingsPanel.tsx +344 -0
  92. package/features/messaging/src/components/chat-multi/ChannelSidebar.tsx +460 -0
  93. package/features/messaging/src/components/chat-multi/CreateGroupModal.tsx +190 -0
  94. package/features/messaging/src/components/chat-multi/InvitationList.tsx +149 -0
  95. package/features/messaging/src/components/chat-multi/NewMessageModal.tsx +159 -0
  96. package/features/messaging/src/hooks/useChatChannel.ts +64 -0
  97. package/features/messaging/src/hooks/useLongPress.ts +74 -0
  98. package/features/messaging/src/hooks/useMultiChannel.ts +142 -0
  99. package/features/messaging/src/messaging-schema.ts +25 -0
  100. package/features/presence-test/feature.json +21 -0
  101. package/features/presence-test/src/PresenceTestPage.tsx +120 -0
  102. package/features/sidebar/feature.json +19 -0
  103. package/features/sidebar/src/AppSidebar.tsx +558 -0
  104. package/features/tasks/feature.json +33 -0
  105. package/features/tasks/src/TasksPage.tsx +598 -0
  106. package/features/tasks/src/tasks-constants.ts +43 -0
  107. package/features/tasks/src/tasks-schema.ts +56 -0
  108. package/features/testing/feature.json +49 -0
  109. package/features/testing/src/TestPage.tsx +145 -0
  110. package/features/testing/src/cpu-burn-action.ts +51 -0
  111. package/features/testing/src/subrequest-burn-action.ts +53 -0
  112. package/features/testing/src/test-schema.ts +23 -0
  113. package/features/topbar/feature.json +18 -0
  114. package/features/topbar/src/Topbar.tsx +353 -0
  115. package/features/tree/feature.json +26 -0
  116. package/features/tree/src/TreeSidebar.tsx +607 -0
  117. package/features/tree/src/tree-constants.ts +68 -0
  118. package/features/tree/src/tree-hooks.ts +258 -0
  119. package/package.json +4 -2
  120. package/scripts/add-feature.cjs +654 -0
@@ -0,0 +1,951 @@
1
+ /**
2
+ * DocsListPage — docs2-style library: sidebar, folders, favorites, grid/list.
3
+ * New docs default to “Untitled Document”; rename in editor (no modal).
4
+ */
5
+
6
+ import { useMemo, useState, useCallback, useEffect, useRef } from 'react'
7
+ import { useNavigate } from 'react-router-dom'
8
+ import type { RecordData } from 'deepspace'
9
+ import { useUser } from 'deepspace'
10
+ import { useQuery } from 'deepspace'
11
+ import { useMutations } from 'deepspace'
12
+ import { ROLES, type Role } from 'deepspace'
13
+ import {
14
+ FileText,
15
+ Plus,
16
+ Trash2,
17
+ LayoutGrid,
18
+ LayoutList,
19
+ SortAsc,
20
+ Search,
21
+ Star,
22
+ Folder,
23
+ ChevronRight,
24
+ MoreVertical,
25
+ Pencil,
26
+ } from 'lucide-react'
27
+ import { LibrarySidebar, readSidebarCollapsed, writeSidebarCollapsed } from './LibrarySidebar'
28
+ import { getFavorites, saveFavorites } from './docs-favorites'
29
+ import type { DocFolderFields, LibraryNavSelection } from './docs-library-types'
30
+ import './docs-ui.css'
31
+
32
+ interface DocumentFields {
33
+ title: string
34
+ ownerId: string
35
+ collaborators?: string
36
+ editors?: string
37
+ folderId?: string
38
+ }
39
+
40
+ type SortOption = 'lastEdited' | 'titleAZ' | 'titleZA'
41
+ type ViewMode = 'grid' | 'list'
42
+
43
+ const SORT_OPTIONS: { value: SortOption; label: string }[] = [
44
+ { value: 'lastEdited', label: 'Last edited' },
45
+ { value: 'titleAZ', label: 'Title A–Z' },
46
+ { value: 'titleZA', label: 'Title Z–A' },
47
+ ]
48
+
49
+ const UNTITLED = 'Untitled Document'
50
+
51
+ function greetingForTime(): string {
52
+ const h = new Date().getHours()
53
+ if (h < 12) return 'Good morning'
54
+ if (h < 17) return 'Good afternoon'
55
+ return 'Good evening'
56
+ }
57
+
58
+ function formatDate(iso: string): string {
59
+ return new Date(iso).toLocaleDateString(undefined, {
60
+ month: 'short',
61
+ day: 'numeric',
62
+ year: 'numeric',
63
+ })
64
+ }
65
+
66
+ function parseIdList(raw: string | undefined): string[] {
67
+ if (!raw) return []
68
+ try {
69
+ const parsed = JSON.parse(raw)
70
+ return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === 'string') : []
71
+ } catch {
72
+ return []
73
+ }
74
+ }
75
+
76
+ export default function DocsListPage() {
77
+ const { user } = useUser()
78
+ const navigate = useNavigate()
79
+ const userRole = (user?.role ?? ROLES.VIEWER) as Role
80
+ const canCreate = userRole === ROLES.MEMBER || userRole === ROLES.ADMIN
81
+
82
+ const [searchQuery, setSearchQuery] = useState('')
83
+ const [sortBy, setSortBy] = useState<SortOption>('lastEdited')
84
+ const [viewMode, setViewMode] = useState<ViewMode>('grid')
85
+ const [sortOpen, setSortOpen] = useState(false)
86
+ const [favorites, setFavorites] = useState<Set<string>>(() => getFavorites())
87
+ const [libraryNav, setLibraryNav] = useState<LibraryNavSelection>({ kind: 'all' })
88
+ const [sidebarCollapsed, setSidebarCollapsed] = useState(readSidebarCollapsed)
89
+ const [renamingId, setRenamingId] = useState<string | null>(null)
90
+ const [renameValue, setRenameValue] = useState('')
91
+ const [folderRenamingId, setFolderRenamingId] = useState<string | null>(null)
92
+ const [folderRenameValue, setFolderRenameValue] = useState('')
93
+ const [moveMenuId, setMoveMenuId] = useState<string | null>(null)
94
+ const [actionsMenuId, setActionsMenuId] = useState<string | null>(null)
95
+
96
+ const { records: documents, status } = useQuery<DocumentFields>('documents', {
97
+ orderBy: 'createdAt',
98
+ orderDir: 'desc',
99
+ })
100
+ const { create, remove, put } = useMutations<DocumentFields>('documents')
101
+
102
+ const { records: folderRecords } = useQuery<DocFolderFields>('doc_folders', {
103
+ orderBy: 'createdAt',
104
+ orderDir: 'asc',
105
+ })
106
+ const {
107
+ create: createFolder,
108
+ put: putFolder,
109
+ remove: removeFolder,
110
+ } = useMutations<DocFolderFields>('doc_folders')
111
+
112
+ const myFolders = useMemo(
113
+ () => (folderRecords ?? []).filter((f) => f.data.ownerId === user?.id),
114
+ [folderRecords, user?.id],
115
+ )
116
+ const sortedFolders = useMemo(
117
+ () => [...myFolders].sort((a, b) => (a.data.name ?? '').localeCompare(b.data.name ?? '')),
118
+ [myFolders],
119
+ )
120
+
121
+ useEffect(() => {
122
+ if (libraryNav.kind !== 'folder') return
123
+ if (!sortedFolders.some((f) => f.recordId === libraryNav.folderId)) {
124
+ setLibraryNav({ kind: 'all' })
125
+ }
126
+ }, [libraryNav, sortedFolders])
127
+
128
+ const displayFirstName = user?.name?.trim().split(/\s+/)[0] ?? 'there'
129
+
130
+ const isOwnedDocument = useCallback(
131
+ (d: RecordData<DocumentFields>) => d.data.ownerId === user?.id,
132
+ [user?.id],
133
+ )
134
+
135
+ const isSharedWithCurrentUser = useCallback(
136
+ (d: RecordData<DocumentFields>) =>
137
+ Boolean(
138
+ user?.id &&
139
+ d.data.ownerId !== user.id &&
140
+ parseIdList(d.data.collaborators).includes(user.id),
141
+ ),
142
+ [user?.id],
143
+ )
144
+
145
+ const ownedDocumentCount = useMemo(
146
+ () => (documents ?? []).filter(isOwnedDocument).length,
147
+ [documents, isOwnedDocument],
148
+ )
149
+
150
+ const listHeading = useMemo(() => {
151
+ if (libraryNav.kind === 'shared') return 'Shared with me'
152
+ if (libraryNav.kind === 'favorites') return 'Favorites'
153
+ if (libraryNav.kind === 'uncategorized') return 'Uncategorized'
154
+ if (libraryNav.kind === 'folder') {
155
+ return (
156
+ sortedFolders.find((f) => f.recordId === libraryNav.folderId)?.data.name?.trim() || 'Folder'
157
+ )
158
+ }
159
+ return 'My Documents'
160
+ }, [libraryNav, sortedFolders])
161
+
162
+ const matchesNav = useCallback(
163
+ (d: RecordData<DocumentFields>) => {
164
+ const fid = d.data.folderId ?? ''
165
+ if (libraryNav.kind === 'all') return isOwnedDocument(d)
166
+ if (libraryNav.kind === 'shared') return isSharedWithCurrentUser(d)
167
+ if (libraryNav.kind === 'favorites') return favorites.has(d.recordId)
168
+ if (libraryNav.kind === 'uncategorized') return isOwnedDocument(d) && fid === ''
169
+ if (libraryNav.kind === 'folder') return isOwnedDocument(d) && fid === libraryNav.folderId
170
+ return true
171
+ },
172
+ [favorites, isOwnedDocument, isSharedWithCurrentUser, libraryNav],
173
+ )
174
+
175
+ const filteredSorted = useMemo(() => {
176
+ let rows = (documents ?? []).filter(matchesNav)
177
+ const q = searchQuery.trim().toLowerCase()
178
+ if (q) rows = rows.filter((d) => d.data.title.toLowerCase().includes(q))
179
+ switch (sortBy) {
180
+ case 'lastEdited':
181
+ rows.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
182
+ break
183
+ case 'titleAZ':
184
+ rows.sort((a, b) => a.data.title.localeCompare(b.data.title))
185
+ break
186
+ case 'titleZA':
187
+ rows.sort((a, b) => b.data.title.localeCompare(a.data.title))
188
+ break
189
+ default:
190
+ break
191
+ }
192
+ return rows
193
+ }, [documents, matchesNav, searchQuery, sortBy])
194
+
195
+ const toggleSidebar = useCallback(() => {
196
+ setSidebarCollapsed((c) => {
197
+ const n = !c
198
+ writeSidebarCollapsed(n)
199
+ return n
200
+ })
201
+ }, [])
202
+
203
+ const toggleFavorite = useCallback((contentId: string) => {
204
+ setFavorites((prev) => {
205
+ const next = new Set(prev)
206
+ if (next.has(contentId)) next.delete(contentId)
207
+ else next.add(contentId)
208
+ saveFavorites(next)
209
+ return next
210
+ })
211
+ }, [])
212
+
213
+ const folderIdForNew = libraryNav.kind === 'folder' ? libraryNav.folderId : ''
214
+
215
+ const handleNewDocument = useCallback(async () => {
216
+ if (!user || !canCreate) return
217
+ const id = await create({
218
+ title: UNTITLED,
219
+ ownerId: user.id,
220
+ collaborators: '[]',
221
+ editors: '[]',
222
+ folderId: folderIdForNew,
223
+ })
224
+ navigate(`/docs/${id}`)
225
+ }, [canCreate, create, folderIdForNew, navigate, user])
226
+
227
+ const handleDelete = useCallback(
228
+ async (docId: string) => {
229
+ if (confirm('Delete this document?')) await remove(docId)
230
+ },
231
+ [remove],
232
+ )
233
+
234
+ const handleRenameCommit = useCallback(
235
+ async (docId: string) => {
236
+ const rec = documents.find((d) => d.recordId === docId)
237
+ const t = renameValue.trim()
238
+ setRenamingId(null)
239
+ if (!rec || !t || t === rec.data.title) return
240
+ await put(docId, { ...rec.data, title: t }).catch(() => {})
241
+ },
242
+ [documents, put, renameValue],
243
+ )
244
+
245
+ const handleCreateFolder = useCallback(
246
+ async (name: string) => {
247
+ if (!user) return
248
+ await createFolder({ name, ownerId: user.id })
249
+ },
250
+ [createFolder, user],
251
+ )
252
+
253
+ const handleDeleteFolder = useCallback(
254
+ async (folderId: string) => {
255
+ const owned = (documents ?? []).filter(
256
+ (d) => d.data.ownerId === user?.id && (d.data.folderId ?? '') === folderId,
257
+ )
258
+ await Promise.all(owned.map((d) => put(d.recordId, { ...d.data, folderId: '' })))
259
+ await removeFolder(folderId)
260
+ setFolderRenamingId((id) => (id === folderId ? null : id))
261
+ setLibraryNav((nav) =>
262
+ nav.kind === 'folder' && nav.folderId === folderId ? { kind: 'all' } : nav,
263
+ )
264
+ },
265
+ [documents, put, removeFolder, user?.id],
266
+ )
267
+
268
+ const commitRenameFolderRef = useRef<() => Promise<void>>(async () => {})
269
+ const commitRenameFolder = useCallback(async () => {
270
+ const id = folderRenamingId
271
+ const trimmed = folderRenameValue.trim()
272
+ setFolderRenamingId(null)
273
+ if (!id || !trimmed) return
274
+ const folder = sortedFolders.find((f) => f.recordId === id)
275
+ if (!folder || trimmed === (folder.data.name ?? '').trim()) return
276
+ await putFolder(id, { ...folder.data, name: trimmed }).catch(() => {})
277
+ }, [folderRenameValue, folderRenamingId, putFolder, sortedFolders])
278
+
279
+ commitRenameFolderRef.current = commitRenameFolder
280
+
281
+ const handleMoveDoc = useCallback(
282
+ async (docId: string, folderId: string) => {
283
+ const rec = documents.find((d) => d.recordId === docId)
284
+ if (!rec) return
285
+ await put(docId, { ...rec.data, folderId })
286
+ setMoveMenuId(null)
287
+ },
288
+ [documents, put],
289
+ )
290
+
291
+ const isLoading = status === 'loading'
292
+
293
+ const accentBtn =
294
+ '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'
295
+ const ghostBtn =
296
+ 'inline-flex h-9 items-center gap-2 rounded-lg border px-4 text-[12px] font-semibold shadow-sm transition-colors'
297
+
298
+ const blankWhen =
299
+ canCreate &&
300
+ libraryNav.kind !== 'favorites' &&
301
+ (libraryNav.kind === 'all' ||
302
+ libraryNav.kind === 'uncategorized' ||
303
+ libraryNav.kind === 'folder')
304
+
305
+ return (
306
+ <div
307
+ className="docs-feature-scope flex min-h-full overflow-hidden"
308
+ style={{ backgroundColor: 'var(--docs-el-bg)', color: 'var(--docs-el-text)' }}
309
+ >
310
+ {user ? (
311
+ <LibrarySidebar
312
+ selection={libraryNav}
313
+ onSelect={setLibraryNav}
314
+ folders={myFolders}
315
+ collapsed={sidebarCollapsed}
316
+ onToggleCollapsed={toggleSidebar}
317
+ onCreateFolder={handleCreateFolder}
318
+ onDeleteFolder={handleDeleteFolder}
319
+ onStartRenameFolder={(f) => {
320
+ setFolderRenamingId(f.recordId)
321
+ setFolderRenameValue(f.data.name ?? '')
322
+ }}
323
+ renamingFolderId={folderRenamingId}
324
+ renameFolderValue={folderRenameValue}
325
+ setRenameFolderValue={setFolderRenameValue}
326
+ onCommitRenameFolder={() => commitRenameFolderRef.current()}
327
+ onCancelRenameFolder={() => setFolderRenamingId(null)}
328
+ />
329
+ ) : null}
330
+
331
+ <div className="min-h-0 min-w-0 flex-1 overflow-y-auto">
332
+ <div className="px-6 pb-10 pt-9 md:px-12 md:pb-12 md:pt-10 lg:px-16 lg:pb-16 lg:pt-12">
333
+ <div className="mb-1 flex min-w-0 items-center justify-between gap-4">
334
+ <h1
335
+ className="min-w-0 text-3xl font-bold leading-tight tracking-tight sm:text-4xl"
336
+ style={{ color: 'var(--docs-el-text)' }}
337
+ >
338
+ {greetingForTime()}, {displayFirstName}
339
+ </h1>
340
+ </div>
341
+ <p className="mb-8 text-[13px] font-medium" style={{ color: 'var(--docs-el-muted)' }}>
342
+ {ownedDocumentCount} {ownedDocumentCount === 1 ? 'document' : 'documents'} in your
343
+ documents.
344
+ </p>
345
+
346
+ <div className="mb-8 flex w-full min-w-0 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
347
+ <div className="relative min-w-0 max-w-md flex-1">
348
+ <Search
349
+ className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2"
350
+ style={{ color: 'var(--docs-el-muted)' }}
351
+ strokeWidth={2}
352
+ aria-hidden
353
+ />
354
+ <input
355
+ type="search"
356
+ value={searchQuery}
357
+ onChange={(e) => setSearchQuery(e.target.value)}
358
+ placeholder="Search your library..."
359
+ 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"
360
+ style={{
361
+ backgroundColor: 'var(--docs-el-surface)',
362
+ borderWidth: 1,
363
+ borderStyle: 'solid',
364
+ borderColor: 'var(--docs-el-line)',
365
+ color: 'var(--docs-el-text)',
366
+ }}
367
+ />
368
+ {searchQuery ? (
369
+ <button
370
+ type="button"
371
+ className="absolute right-2 top-1/2 -translate-y-1/2 rounded-md px-2 py-1 text-[11px] font-medium"
372
+ style={{ color: 'var(--docs-el-muted)' }}
373
+ onClick={() => setSearchQuery('')}
374
+ >
375
+ Clear
376
+ </button>
377
+ ) : null}
378
+ </div>
379
+ <div className="flex shrink-0 flex-wrap items-center justify-end gap-3 sm:ml-auto">
380
+ {canCreate ? (
381
+ <button
382
+ type="button"
383
+ onClick={() => void handleNewDocument()}
384
+ className={accentBtn}
385
+ style={{ backgroundColor: 'var(--docs-el-accent)' }}
386
+ >
387
+ <Plus className="h-4 w-4" strokeWidth={2.5} />
388
+ New Document
389
+ </button>
390
+ ) : null}
391
+ </div>
392
+ </div>
393
+
394
+ {canCreate && sortedFolders.length > 0 && (
395
+ <div className="mb-10 grid grid-cols-1 gap-3 md:grid-cols-4">
396
+ {sortedFolders.map((folder) => {
397
+ const selected =
398
+ libraryNav.kind === 'folder' && libraryNav.folderId === folder.recordId
399
+ return (
400
+ <button
401
+ key={folder.recordId}
402
+ type="button"
403
+ onClick={() => setLibraryNav({ kind: 'folder', folderId: folder.recordId })}
404
+ className="group flex min-w-0 items-center rounded-xl border p-3.5 text-left shadow-sm transition-all"
405
+ style={{
406
+ borderColor: selected
407
+ ? 'color-mix(in srgb, var(--docs-el-accent) 50%, transparent)'
408
+ : 'var(--docs-el-line)',
409
+ backgroundColor: 'var(--docs-el-surface)',
410
+ boxShadow: selected
411
+ ? '0 0 0 2px color-mix(in srgb, var(--docs-el-accent) 15%, transparent)'
412
+ : undefined,
413
+ }}
414
+ >
415
+ <div
416
+ className="rounded-lg p-2 transition-all group-hover:bg-[color-mix(in_srgb,var(--docs-el-accent)_5%,transparent)]"
417
+ style={{
418
+ backgroundColor: 'var(--docs-el-bg)',
419
+ color: 'var(--docs-el-muted)',
420
+ }}
421
+ >
422
+ <Folder className="h-4 w-4" />
423
+ </div>
424
+ <span className="min-w-0 flex-1 truncate pl-3 text-[13px] font-semibold">
425
+ {folder.data.name}
426
+ </span>
427
+ <ChevronRight className="h-3 w-3 shrink-0 opacity-50" />
428
+ </button>
429
+ )
430
+ })}
431
+ </div>
432
+ )}
433
+
434
+ <div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
435
+ <h2
436
+ className="text-[11px] font-bold uppercase tracking-widest"
437
+ style={{ color: 'var(--docs-el-muted)' }}
438
+ >
439
+ {listHeading}
440
+ </h2>
441
+ <div className="flex flex-wrap items-center gap-2 sm:gap-3">
442
+ <div className="relative">
443
+ <button
444
+ type="button"
445
+ onClick={() => setSortOpen(!sortOpen)}
446
+ className={ghostBtn}
447
+ style={{
448
+ borderColor: 'var(--docs-el-line)',
449
+ backgroundColor: 'var(--docs-el-surface)',
450
+ color: 'var(--docs-el-muted)',
451
+ }}
452
+ >
453
+ <SortAsc className="h-3.5 w-3.5" />
454
+ {SORT_OPTIONS.find((o) => o.value === sortBy)?.label}
455
+ </button>
456
+ {sortOpen ? (
457
+ <>
458
+ <button
459
+ type="button"
460
+ className="fixed inset-0 z-40 cursor-default bg-transparent"
461
+ aria-label="Close menu"
462
+ onClick={() => setSortOpen(false)}
463
+ />
464
+ <div
465
+ className="absolute right-0 top-full z-50 mt-1 min-w-[168px] rounded-lg border py-1 shadow-lg"
466
+ style={{
467
+ borderColor: 'var(--docs-el-line)',
468
+ backgroundColor: 'var(--docs-el-surface)',
469
+ }}
470
+ >
471
+ {SORT_OPTIONS.map((opt) => (
472
+ <button
473
+ key={opt.value}
474
+ type="button"
475
+ onClick={() => {
476
+ setSortBy(opt.value)
477
+ setSortOpen(false)
478
+ }}
479
+ className="w-full px-3 py-1.5 text-left text-sm transition-colors hover:bg-black/[0.03] dark:hover:bg-white/[0.05]"
480
+ style={{
481
+ color:
482
+ sortBy === opt.value
483
+ ? 'var(--docs-el-accent)'
484
+ : 'var(--docs-el-text)',
485
+ fontWeight: sortBy === opt.value ? 600 : 400,
486
+ }}
487
+ >
488
+ {opt.label}
489
+ </button>
490
+ ))}
491
+ </div>
492
+ </>
493
+ ) : null}
494
+ </div>
495
+
496
+ <div
497
+ className="flex items-center rounded-lg p-0.5"
498
+ style={{
499
+ borderWidth: 1,
500
+ borderStyle: 'solid',
501
+ borderColor: 'var(--docs-el-line)',
502
+ backgroundColor: 'color-mix(in srgb, var(--docs-el-text) 5%, transparent)',
503
+ }}
504
+ >
505
+ <button
506
+ type="button"
507
+ onClick={() => setViewMode('grid')}
508
+ className="rounded-md p-1.5 transition-all"
509
+ style={
510
+ viewMode === 'grid'
511
+ ? {
512
+ backgroundColor: 'var(--docs-el-surface)',
513
+ color: 'var(--docs-el-text)',
514
+ boxShadow: '0 1px 2px rgba(0,0,0,0.06)',
515
+ }
516
+ : { color: 'var(--docs-el-muted)' }
517
+ }
518
+ title="Grid view"
519
+ >
520
+ <LayoutGrid className="h-[13px] w-[13px]" />
521
+ </button>
522
+ <button
523
+ type="button"
524
+ onClick={() => setViewMode('list')}
525
+ className="rounded-md p-1.5 transition-all"
526
+ style={
527
+ viewMode === 'list'
528
+ ? {
529
+ backgroundColor: 'var(--docs-el-surface)',
530
+ color: 'var(--docs-el-text)',
531
+ boxShadow: '0 1px 2px rgba(0,0,0,0.06)',
532
+ }
533
+ : { color: 'var(--docs-el-muted)' }
534
+ }
535
+ title="List view"
536
+ >
537
+ <LayoutList className="h-[13px] w-[13px]" />
538
+ </button>
539
+ </div>
540
+ </div>
541
+ </div>
542
+
543
+ {isLoading ? (
544
+ <div className="flex items-center justify-center py-20">
545
+ <div
546
+ className="h-10 w-10 animate-spin rounded-full border-2"
547
+ style={{
548
+ borderColor: 'var(--docs-el-line)',
549
+ borderTopColor: 'var(--docs-el-accent)',
550
+ }}
551
+ />
552
+ </div>
553
+ ) : filteredSorted.length === 0 ? (
554
+ <div className="flex flex-col items-center justify-center px-4 py-16 text-center">
555
+ <div
556
+ className="mb-5 flex h-16 w-16 items-center justify-center rounded-2xl"
557
+ style={{
558
+ backgroundColor: 'var(--docs-el-surface)',
559
+ boxShadow:
560
+ '0 0 0 1px var(--docs-el-line), 0 1px 2px color-mix(in srgb, var(--docs-el-text) 8%, transparent)',
561
+ }}
562
+ >
563
+ <FileText
564
+ className="h-8 w-8"
565
+ strokeWidth={1.5}
566
+ style={{ color: 'var(--docs-el-muted)' }}
567
+ aria-hidden
568
+ />
569
+ </div>
570
+ <h3 className="mb-2 text-lg font-semibold" style={{ color: 'var(--docs-el-text)' }}>
571
+ {searchQuery ? 'No matches' : 'No documents yet'}
572
+ </h3>
573
+ <p className="mb-6 max-w-sm text-sm" style={{ color: 'var(--docs-el-muted)' }}>
574
+ {canCreate && !searchQuery
575
+ ? 'Create your first document with New Document above'
576
+ : searchQuery
577
+ ? 'Try a different search'
578
+ : 'No documents yet'}
579
+ </p>
580
+ </div>
581
+ ) : viewMode === 'grid' ? (
582
+ <div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5">
583
+ {blankWhen ? (
584
+ <button
585
+ type="button"
586
+ onClick={() => void handleNewDocument()}
587
+ className="group w-full cursor-pointer rounded-xl border-2 border-dashed p-4 text-left shadow-sm transition-all hover:shadow-md"
588
+ style={{
589
+ borderColor: 'var(--docs-el-line)',
590
+ backgroundColor: 'color-mix(in srgb, var(--docs-el-surface) 20%, transparent)',
591
+ }}
592
+ >
593
+ <div
594
+ className="relative mb-4 aspect-[3/4] w-full overflow-hidden rounded-lg border shadow-sm"
595
+ style={{
596
+ borderColor: 'var(--docs-el-line)',
597
+ backgroundColor: 'var(--docs-el-bg)',
598
+ }}
599
+ >
600
+ <div className="flex h-full w-full items-center justify-center">
601
+ <div
602
+ className="flex h-10 w-10 items-center justify-center rounded-full ring-1 transition-colors group-hover:text-[color:var(--docs-el-accent)]"
603
+ style={{
604
+ backgroundColor: 'var(--docs-el-surface)',
605
+ color: 'var(--docs-el-muted)',
606
+ boxShadow: '0 0 0 1px var(--docs-el-line)',
607
+ }}
608
+ >
609
+ <Plus className="h-5 w-5" strokeWidth={2.5} />
610
+ </div>
611
+ </div>
612
+ </div>
613
+ <h3
614
+ className="truncate text-[13px] font-semibold leading-snug transition-colors group-hover:text-[color:var(--docs-el-accent)]"
615
+ style={{ color: 'var(--docs-el-text)' }}
616
+ >
617
+ Blank doc
618
+ </h3>
619
+ <p
620
+ className="mt-0.5 text-[10px] font-medium"
621
+ style={{ color: 'var(--docs-el-muted)' }}
622
+ >
623
+ Creates “{UNTITLED}” — rename in editor
624
+ </p>
625
+ </button>
626
+ ) : null}
627
+
628
+ {filteredSorted.map((doc) => {
629
+ const isOwner = doc.data.ownerId === user?.id
630
+ const isFav = favorites.has(doc.recordId)
631
+ const dateLabel = formatDate(doc.createdAt)
632
+ const fid = doc.data.folderId ?? ''
633
+
634
+ return (
635
+ <div
636
+ key={doc.recordId}
637
+ role="button"
638
+ tabIndex={0}
639
+ className="group relative cursor-pointer rounded-xl border p-4 shadow-sm transition-all hover:-translate-y-0.5 hover:shadow-md"
640
+ style={{
641
+ borderColor: 'var(--docs-el-line)',
642
+ backgroundColor: 'var(--docs-el-surface)',
643
+ }}
644
+ onClick={() => navigate(`/docs/${doc.recordId}`)}
645
+ onKeyDown={(e) => {
646
+ if (e.key === 'Enter' || e.key === ' ') {
647
+ e.preventDefault()
648
+ navigate(`/docs/${doc.recordId}`)
649
+ }
650
+ }}
651
+ >
652
+ <div
653
+ className="relative mb-4 aspect-[3/4] w-full overflow-hidden rounded-lg border shadow-sm"
654
+ style={{
655
+ borderColor: 'var(--docs-el-line)',
656
+ backgroundColor: 'var(--docs-el-bg)',
657
+ }}
658
+ >
659
+ <div className="flex h-full w-full items-center justify-center">
660
+ <FileText className="h-10 w-10 opacity-[0.18]" strokeWidth={1.25} />
661
+ </div>
662
+ </div>
663
+ <div className="flex items-start justify-between gap-2">
664
+ <div className="min-w-0 flex-1">
665
+ {renamingId === doc.recordId ? (
666
+ <input
667
+ value={renameValue}
668
+ onChange={(e) => setRenameValue(e.target.value)}
669
+ onBlur={() => void handleRenameCommit(doc.recordId)}
670
+ onKeyDown={(e) => {
671
+ if (e.key === 'Enter') void handleRenameCommit(doc.recordId)
672
+ if (e.key === 'Escape') setRenamingId(null)
673
+ }}
674
+ onClick={(e) => e.stopPropagation()}
675
+ 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"
676
+ style={{
677
+ borderColor: 'var(--docs-el-line)',
678
+ backgroundColor: 'var(--docs-el-bg)',
679
+ color: 'var(--docs-el-text)',
680
+ }}
681
+ autoFocus
682
+ />
683
+ ) : (
684
+ <h3
685
+ className="truncate text-[13px] font-semibold leading-snug transition-colors group-hover:text-[color:var(--docs-el-accent)]"
686
+ style={{ color: 'var(--docs-el-text)' }}
687
+ >
688
+ {doc.data.title}
689
+ </h3>
690
+ )}
691
+ <p
692
+ className="mt-0.5 text-[10px] font-medium leading-4 tracking-tight"
693
+ style={{ color: 'var(--docs-el-muted)' }}
694
+ >
695
+ {dateLabel}
696
+ {isOwner ? ' · You' : ''}
697
+ {!isOwner ? ' · Shared with you' : ''}
698
+ {fid
699
+ ? ` · ${sortedFolders.find((f) => f.recordId === fid)?.data.name ?? 'Folder'}`
700
+ : ''}
701
+ </p>
702
+ </div>
703
+ {isOwner ? (
704
+ <div className="flex shrink-0 items-center gap-0.5">
705
+ <button
706
+ type="button"
707
+ title={isFav ? 'Remove favorite' : 'Add favorite'}
708
+ onClick={(e) => {
709
+ e.stopPropagation()
710
+ toggleFavorite(doc.recordId)
711
+ }}
712
+ className="rounded-lg p-1.5 transition-colors hover:bg-black/[0.05] dark:hover:bg-white/[0.06]"
713
+ style={{ color: 'var(--docs-el-muted)' }}
714
+ >
715
+ <Star
716
+ className={`h-4 w-4 ${isFav ? 'fill-yellow-500 text-yellow-500' : ''}`}
717
+ />
718
+ </button>
719
+ <div className="relative">
720
+ <button
721
+ type="button"
722
+ title="More"
723
+ onClick={(e) => {
724
+ e.stopPropagation()
725
+ setActionsMenuId((id) =>
726
+ id === doc.recordId ? null : doc.recordId,
727
+ )
728
+ setMoveMenuId(null)
729
+ }}
730
+ className="rounded-lg p-1.5"
731
+ style={{ color: 'var(--docs-el-muted)' }}
732
+ >
733
+ <MoreVertical className="h-4 w-4" />
734
+ </button>
735
+ {actionsMenuId === doc.recordId ? (
736
+ <>
737
+ <button
738
+ type="button"
739
+ className="fixed inset-0 z-40 cursor-default bg-transparent"
740
+ aria-label="Close"
741
+ onClick={(e) => {
742
+ e.stopPropagation()
743
+ setActionsMenuId(null)
744
+ }}
745
+ />
746
+ <div
747
+ className="absolute right-0 top-full z-50 mt-1 min-w-[160px] rounded-lg border py-1 shadow-lg"
748
+ style={{
749
+ borderColor: 'var(--docs-el-line)',
750
+ backgroundColor: 'var(--docs-el-surface)',
751
+ color: 'var(--docs-el-text)',
752
+ }}
753
+ onClick={(e) => e.stopPropagation()}
754
+ >
755
+ <button
756
+ type="button"
757
+ className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-black/[0.04] dark:hover:bg-white/[0.06]"
758
+ onClick={() => {
759
+ setRenamingId(doc.recordId)
760
+ setRenameValue(doc.data.title)
761
+ setActionsMenuId(null)
762
+ }}
763
+ >
764
+ <Pencil className="h-3.5 w-3.5" /> Rename
765
+ </button>
766
+ <button
767
+ type="button"
768
+ className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-black/[0.04] dark:hover:bg-white/[0.06]"
769
+ onClick={() => {
770
+ setMoveMenuId(
771
+ moveMenuId === doc.recordId ? null : doc.recordId,
772
+ )
773
+ }}
774
+ >
775
+ <Folder className="h-3.5 w-3.5" /> Move to…
776
+ </button>
777
+ {moveMenuId === doc.recordId ? (
778
+ <div
779
+ className="border-t px-2 py-1"
780
+ style={{ borderColor: 'var(--docs-el-line)' }}
781
+ >
782
+ <button
783
+ type="button"
784
+ className="block w-full rounded px-2 py-1 text-left text-xs hover:bg-black/[0.04]"
785
+ disabled={fid === ''}
786
+ onClick={() => void handleMoveDoc(doc.recordId, '')}
787
+ >
788
+ Uncategorized
789
+ </button>
790
+ {sortedFolders.map((f) => (
791
+ <button
792
+ key={f.recordId}
793
+ type="button"
794
+ disabled={fid === f.recordId}
795
+ className="block w-full rounded px-2 py-1 text-left text-xs hover:bg-black/[0.04] disabled:opacity-40"
796
+ onClick={() =>
797
+ void handleMoveDoc(doc.recordId, f.recordId)
798
+ }
799
+ >
800
+ {f.data.name}
801
+ </button>
802
+ ))}
803
+ </div>
804
+ ) : null}
805
+ <button
806
+ type="button"
807
+ className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-red-600 hover:bg-red-500/10"
808
+ onClick={() => {
809
+ setActionsMenuId(null)
810
+ void handleDelete(doc.recordId)
811
+ }}
812
+ >
813
+ <Trash2 className="h-3.5 w-3.5" /> Delete
814
+ </button>
815
+ </div>
816
+ </>
817
+ ) : null}
818
+ </div>
819
+ </div>
820
+ ) : null}
821
+ </div>
822
+ </div>
823
+ )
824
+ })}
825
+ </div>
826
+ ) : (
827
+ <div
828
+ className="overflow-hidden rounded-xl border shadow-sm"
829
+ style={{
830
+ borderColor: 'var(--docs-el-line)',
831
+ backgroundColor: 'var(--docs-el-surface)',
832
+ }}
833
+ >
834
+ <div
835
+ className="flex items-center gap-4 border-b px-4 py-2 text-[10px] font-bold uppercase tracking-widest"
836
+ style={{
837
+ borderColor: 'var(--docs-el-line)',
838
+ backgroundColor: 'color-mix(in srgb, var(--docs-el-text) 3%, transparent)',
839
+ color: 'var(--docs-el-muted)',
840
+ }}
841
+ >
842
+ <div className="min-w-0 flex-1">Name</div>
843
+ <div className="hidden w-28 shrink-0 md:block">Folder</div>
844
+ <div className="hidden w-36 shrink-0 text-right md:block">Modified</div>
845
+ <div className="w-24 shrink-0" />
846
+ </div>
847
+ {filteredSorted.map((doc) => {
848
+ const isOwner = doc.data.ownerId === user?.id
849
+ const isFav = favorites.has(doc.recordId)
850
+ const dateLabel = formatDate(doc.createdAt)
851
+ const fid = doc.data.folderId ?? ''
852
+ const folderLabel = fid
853
+ ? (sortedFolders.find((f) => f.recordId === fid)?.data.name ?? '—')
854
+ : '—'
855
+
856
+ return (
857
+ <div
858
+ key={doc.recordId}
859
+ role="button"
860
+ tabIndex={0}
861
+ className="group flex cursor-pointer items-center gap-4 border-b px-4 py-3 transition-colors last:border-b-0 hover:bg-black/[0.02] dark:hover:bg-white/[0.03]"
862
+ style={{ borderColor: 'var(--docs-el-line)' }}
863
+ onClick={() => navigate(`/docs/${doc.recordId}`)}
864
+ onKeyDown={(e) => {
865
+ if (e.key === 'Enter' || e.key === ' ') {
866
+ e.preventDefault()
867
+ navigate(`/docs/${doc.recordId}`)
868
+ }
869
+ }}
870
+ >
871
+ <div className="flex min-w-0 flex-1 items-center gap-4">
872
+ <div
873
+ className="hidden h-14 w-11 shrink-0 overflow-hidden rounded-md border sm:block"
874
+ style={{
875
+ borderColor: 'var(--docs-el-line)',
876
+ backgroundColor: 'var(--docs-el-bg)',
877
+ }}
878
+ >
879
+ <div className="flex h-full w-full items-center justify-center">
880
+ <FileText className="h-5 w-5 opacity-25" />
881
+ </div>
882
+ </div>
883
+ <div className="min-w-0 flex-1">
884
+ <h4
885
+ className="line-clamp-2 text-[13px] font-semibold leading-snug transition-colors group-hover:text-[color:var(--docs-el-accent)]"
886
+ style={{ color: 'var(--docs-el-text)' }}
887
+ >
888
+ {doc.data.title}
889
+ </h4>
890
+ <p
891
+ className="mt-0.5 text-[10px] font-medium md:hidden"
892
+ style={{ color: 'var(--docs-el-muted)' }}
893
+ >
894
+ {dateLabel}
895
+ {isOwner ? ' · You' : ' · Shared with you'}
896
+ {folderLabel !== '—' ? ` · ${folderLabel}` : ''}
897
+ </p>
898
+ </div>
899
+ </div>
900
+ <div
901
+ className="hidden w-28 shrink-0 truncate text-[12px] md:block"
902
+ style={{ color: 'var(--docs-el-muted)' }}
903
+ >
904
+ {folderLabel}
905
+ </div>
906
+ <div
907
+ className="hidden w-36 shrink-0 text-right text-[12px] md:block"
908
+ style={{ color: 'var(--docs-el-muted)' }}
909
+ >
910
+ {dateLabel}
911
+ </div>
912
+ <div className="flex w-24 shrink-0 justify-end gap-1">
913
+ {isOwner ? (
914
+ <>
915
+ <button
916
+ type="button"
917
+ onClick={(e) => {
918
+ e.stopPropagation()
919
+ toggleFavorite(doc.recordId)
920
+ }}
921
+ className="rounded-lg p-1.5"
922
+ style={{ color: 'var(--docs-el-muted)' }}
923
+ >
924
+ <Star
925
+ className={`h-4 w-4 ${isFav ? 'fill-yellow-500 text-yellow-500' : ''}`}
926
+ />
927
+ </button>
928
+ <button
929
+ type="button"
930
+ onClick={(e) => {
931
+ e.stopPropagation()
932
+ void handleDelete(doc.recordId)
933
+ }}
934
+ className="rounded-lg p-1.5 opacity-0 transition-opacity group-hover:opacity-100"
935
+ style={{ color: 'var(--docs-el-muted)' }}
936
+ >
937
+ <Trash2 className="h-4 w-4" />
938
+ </button>
939
+ </>
940
+ ) : null}
941
+ </div>
942
+ </div>
943
+ )
944
+ })}
945
+ </div>
946
+ )}
947
+ </div>
948
+ </div>
949
+ </div>
950
+ )
951
+ }