@kernhq/module-quire 0.10.3 → 0.10.5

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kernhq/module-quire",
3
- "version": "0.10.3",
3
+ "version": "0.10.5",
4
4
  "description": "Kern Quire: collaborative documents, spaces and page trees",
5
5
  "homepage": "https://github.com/KernAIO/module-quire#readme",
6
6
  "license": "AGPL-3.0-only",
@@ -54,7 +54,7 @@
54
54
  "peerDependencies": {
55
55
  "@kernhq/contracts": "^0.7.0",
56
56
  "@kernhq/kernel": "^0.7.2",
57
- "@kernhq/ui": "^0.10.0",
57
+ "@kernhq/ui": "^0.12.0",
58
58
  "@tanstack/svelte-query": "^6.1.0",
59
59
  "svelte": "^5.46.0"
60
60
  },
@@ -75,7 +75,7 @@
75
75
  "@kernhq/contracts": "^0.7.0",
76
76
  "@kernhq/kernel": "^0.7.2",
77
77
  "@kernhq/tsconfig": "^0.1.0",
78
- "@kernhq/ui": "^0.10.0",
78
+ "@kernhq/ui": "^0.12.0",
79
79
  "@tanstack/svelte-query": "^6.1.0",
80
80
  "@types/node": "^24.0.0",
81
81
  "@types/pg": "^8.15.0",
@@ -73,12 +73,19 @@ const user = $derived({
73
73
  the narrow schema with issue descriptions, and only a page has a reader (`renderPageDoc`) able
74
74
  to draw all of this outside the browser.
75
75
  -->
76
+ <!--
77
+ `label` is not decoration: the surface carries `role="textbox"`, and a textbox with no
78
+ accessible name is announced as nothing at all. This is the wiki's main writing surface, so
79
+ it was the one control in the product that most needed a name and did not have one. The page
80
+ title is the honest name for it; `page_body` covers an untitled page.
81
+ -->
76
82
  <CollaborativeEditor
77
83
  {url}
78
84
  {name}
79
85
  {user}
80
86
  page
81
87
  placeholder={t('editor_placeholder')}
88
+ label={doc.title || t('page_body')}
82
89
  {onpeers}
83
90
  {onstatus}
84
91
  {commentRanges}
@@ -30,7 +30,9 @@ export const en: Record<string, Message> = {
30
30
  'quire.comments_empty': 'No comments yet',
31
31
  'quire.comments_empty_desc': 'Select some text to ask about it.',
32
32
  'quire.edited_ago': 'Edited {when}',
33
+ 'quire.edited_ago_by': 'Edited {when} by {who}',
33
34
  'quire.editor_mock': 'Not connected to the collaboration service',
35
+ 'quire.page_body': 'Page body',
34
36
  'quire.editor_mock_desc':
35
37
  'This is the demo interface, which has no server behind it. Writing here would not be saved.',
36
38
  'quire.editor_no_session': 'You are not signed in',
@@ -366,7 +368,9 @@ const ar: Record<string, Message> = {
366
368
  'quire.comments_empty': 'لا توجد تعليقات بعد',
367
369
  'quire.comments_empty_desc': 'حدّد نصًا لتسأل عنه.',
368
370
  'quire.edited_ago': 'عُدّلت {when}',
371
+ 'quire.edited_ago_by': 'عُدّلت {when} بواسطة {who}',
369
372
  'quire.editor_mock': 'غير متصل بخدمة التعاون',
373
+ 'quire.page_body': 'متن الصفحة',
370
374
  'quire.editor_mock_desc': 'هذه واجهة العرض التجريبي، ولا يوجد خادم خلفها. لن يُحفظ ما تكتبه هنا.',
371
375
  'quire.editor_no_session': 'لم تسجّل الدخول',
372
376
  'quire.editor_no_session_desc': 'سجّل الدخول مرة أخرى للكتابة في هذه الصفحة.',
@@ -713,7 +717,9 @@ const de: Record<string, Message> = {
713
717
  'quire.comments_empty': 'Noch keine Kommentare',
714
718
  'quire.comments_empty_desc': 'Markiere Text, um dazu etwas zu fragen.',
715
719
  'quire.edited_ago': 'Bearbeitet {when}',
720
+ 'quire.edited_ago_by': 'Bearbeitet {when} von {who}',
716
721
  'quire.editor_mock': 'Nicht mit dem Kollaborationsdienst verbunden',
722
+ 'quire.page_body': 'Seiteninhalt',
717
723
  'quire.editor_mock_desc':
718
724
  'Dies ist die Demo-Oberfläche, hinter der kein Server steht. Was du hier schreibst, wird nicht gespeichert.',
719
725
  'quire.editor_no_session': 'Du bist nicht angemeldet',
@@ -1052,7 +1058,9 @@ const fa: Record<string, Message> = {
1052
1058
  'quire.comments_empty': 'هنوز دیدگاهی نیست',
1053
1059
  'quire.comments_empty_desc': 'متنی را انتخاب کنید تا دربارهٔ آن بپرسید.',
1054
1060
  'quire.edited_ago': 'ویرایش {when}',
1061
+ 'quire.edited_ago_by': 'ویرایش {when} توسط {who}',
1055
1062
  'quire.editor_mock': 'به سرویس همکاری وصل نیست',
1063
+ 'quire.page_body': 'متن صفحه',
1056
1064
  'quire.editor_mock_desc': 'این نسخهٔ نمایشی است و سروری پشت آن نیست. آنچه اینجا بنویسید ذخیره نمی‌شود.',
1057
1065
  'quire.editor_no_session': 'وارد نشده‌اید',
1058
1066
  'quire.editor_no_session_desc': 'برای نوشتن در این صفحه دوباره وارد شوید.',
@@ -1381,7 +1389,9 @@ const tr: Record<string, Message> = {
1381
1389
  'quire.comments_empty': 'Henüz yorum yok',
1382
1390
  'quire.comments_empty_desc': 'Hakkında soru sormak için bir metin seçin.',
1383
1391
  'quire.edited_ago': '{when} düzenlendi',
1392
+ 'quire.edited_ago_by': '{who} tarafından {when} düzenlendi',
1384
1393
  'quire.editor_mock': 'İş birliği hizmetine bağlı değil',
1394
+ 'quire.page_body': 'Sayfa gövdesi',
1385
1395
  'quire.editor_mock_desc': 'Bu, arkasında sunucu olmayan tanıtım arayüzü. Burada yazdıklarınız kaydedilmez.',
1386
1396
  'quire.editor_no_session': 'Oturum açmadınız',
1387
1397
  'quire.editor_no_session_desc': 'Bu sayfada yazmak için yeniden oturum açın.',
@@ -1,9 +1,13 @@
1
1
  import type {
2
+ Comment,
3
+ CommentAnchor,
4
+ CommentThread,
2
5
  Database,
3
6
  DatabaseRef,
4
7
  Row as DatabaseRow,
5
8
  Page,
6
9
  PageNode,
10
+ PageVersion,
7
11
  Property,
8
12
  PropertyConfig,
9
13
  PropertyType,
@@ -113,8 +117,13 @@ export function createMockQuireApi() {
113
117
  })
114
118
 
115
119
  const pages: Row[] = [
116
- page(101, uid(1), 'Welcome', 'a'),
117
- page(102, uid(1), 'Working here', 'b'),
120
+ page(101, uid(1), 'Welcome', 'a', null, { publishedVersionId: uid(152) }),
121
+ // A page with a draft readers cannot see yet, so the banner above the body is reachable. The
122
+ // server only ever sets this on a page that has been published once, and neither does this.
123
+ page(102, uid(1), 'Working here', 'b', null, {
124
+ publishedVersionId: uid(153),
125
+ hasUnpublishedChanges: true,
126
+ }),
118
127
  page(103, uid(1), 'Your first week', 'ba', 102),
119
128
  page(104, uid(1), 'Time off', 'bb', 102),
120
129
  page(105, uid(1), 'Expenses', 'c', null, { kind: 'live' }),
@@ -237,6 +246,138 @@ export function createMockQuireApi() {
237
246
  pages.push(row)
238
247
  }
239
248
 
249
+ /**
250
+ * The people the app's own mock signs you in as.
251
+ *
252
+ * Written out rather than derived: this module cannot see the shell's mock, and a comment with
253
+ * nobody's id on it loses the delete control only its author is offered — so the margin would
254
+ * look complete and be missing the one action that belongs to you.
255
+ */
256
+ const ME = '01920000-0000-7000-8000-000000000001'
257
+ const COLLEAGUE = '01920000-0000-7000-8000-000000000002'
258
+
259
+ /**
260
+ * What the pages used to say, and what people have asked about them.
261
+ *
262
+ * Seeded rather than left empty. Version history and the comment margin are two of the three
263
+ * things a page screen is for, and until these existed the demo interface answered the history
264
+ * sheet with "The history could not be loaded" and never drew a margin at all — in exactly the
265
+ * environment used for demos and end-to-end tests. Two pages differ on purpose, so a page with a
266
+ * margin and a page without one are both reachable.
267
+ */
268
+ const version = (
269
+ n: number,
270
+ pageId: string,
271
+ kind: PageVersion['kind'],
272
+ label: string | null,
273
+ preview: string,
274
+ msAgo: number,
275
+ authorId: string,
276
+ ): PageVersion => ({
277
+ id: uid(n),
278
+ workspaceId: '' as PageVersion['workspaceId'],
279
+ pageId,
280
+ kind,
281
+ label,
282
+ preview,
283
+ // The server reports the length of the encoded document; the order of magnitude is all any
284
+ // screen does with it.
285
+ size: preview.length * 4,
286
+ authorId: authorId as PageVersion['authorId'],
287
+ createdAt: iso(msAgo),
288
+ // Which version readers are served is a property of the page, so it is worked out on the way
289
+ // out rather than stored here twice and left to disagree with itself.
290
+ published: false,
291
+ })
292
+
293
+ const versions: PageVersion[] = [
294
+ version(150, uid(101), 'publish', 'The first handbook', 'Welcome to Northstar.', 9e7, ME),
295
+ version(
296
+ 151,
297
+ uid(101),
298
+ 'auto',
299
+ null,
300
+ 'Welcome to Northstar. We are a small team and we write things down.',
301
+ 108e5,
302
+ COLLEAGUE,
303
+ ),
304
+ version(
305
+ 152,
306
+ uid(101),
307
+ 'publish',
308
+ null,
309
+ 'Welcome to Northstar. We are a small team and we write things down, so that nobody has to ask the same question twice.',
310
+ 72e5,
311
+ ME,
312
+ ),
313
+ version(153, uid(102), 'publish', null, 'How this team works, in one page.', 108e5, ME),
314
+ version(
315
+ 154,
316
+ uid(102),
317
+ 'auto',
318
+ null,
319
+ 'How this team works, in one page. Start with your first week.',
320
+ 36e5,
321
+ COLLEAGUE,
322
+ ),
323
+ ]
324
+
325
+ const richDoc = (text: string): Record<string, unknown> => ({
326
+ type: 'doc',
327
+ content: [{ type: 'paragraph', content: [{ type: 'text', text }] }],
328
+ })
329
+
330
+ /** The same dumb walk the server does: whatever the editor produced, minus everything but text. */
331
+ const flatten = (body: unknown): string => {
332
+ const out: string[] = []
333
+ const walk = (node: unknown): void => {
334
+ if (!node || typeof node !== 'object') return
335
+ const n = node as { text?: unknown; content?: unknown[] }
336
+ if (typeof n.text === 'string') out.push(n.text)
337
+ if (Array.isArray(n.content)) for (const child of n.content) walk(child)
338
+ }
339
+ walk(body)
340
+ return out.join(' ').replace(/\s+/g, ' ').trim()
341
+ }
342
+
343
+ const comment = (
344
+ n: number,
345
+ pageId: string,
346
+ threadId: string,
347
+ parentId: string | null,
348
+ authorId: string,
349
+ text: string,
350
+ msAgo: number,
351
+ ): Comment => ({
352
+ id: uid(n),
353
+ workspaceId: '' as Comment['workspaceId'],
354
+ pageId,
355
+ parentId,
356
+ threadId,
357
+ authorId: authorId as Comment['authorId'],
358
+ body: richDoc(text),
359
+ bodyText: text,
360
+ mentionIds: [],
361
+ /*
362
+ * No anchor, and none of these quote anything.
363
+ *
364
+ * An anchor is a pair of Yjs relative positions into a document that only exists behind the
365
+ * collab service, and there is no collab service here — a made-up one would point at nothing
366
+ * and the editor would draw a highlight over the wrong words, which is worse than no highlight.
367
+ */
368
+ anchor: null,
369
+ quotedText: '',
370
+ resolvedAt: null,
371
+ resolvedBy: null,
372
+ editedAt: null,
373
+ createdAt: iso(msAgo),
374
+ })
375
+
376
+ const comments: Comment[] = [
377
+ comment(160, uid(101), uid(160), null, COLLEAGUE, 'Should this mention the on-call rota?', 72e5),
378
+ comment(161, uid(101), uid(160), uid(160), ME, 'Good point — I will link to it from here.', 36e5),
379
+ ]
380
+
240
381
  let seq = 900
241
382
  const nextId = () => uid(++seq)
242
383
  const strip = ({ _order, ...p }: Row): Page => p
@@ -261,6 +402,43 @@ export function createMockQuireApi() {
261
402
 
262
403
  const notFound = (what: string) => Object.assign(new Error(`${what} not found`), { code: 'NOT_FOUND' })
263
404
 
405
+ const theVersion = (id: string): PageVersion => {
406
+ const found = versions.find((v) => v.id === id)
407
+ if (!found) throw notFound('Version')
408
+ return found
409
+ }
410
+ const theComment = (id: string): Comment => {
411
+ const found = comments.find((c) => c.id === id)
412
+ if (!found) throw notFound('Comment')
413
+ return found
414
+ }
415
+
416
+ /** Which version a reader is served, which the list and the sheet both have to agree about. */
417
+ const publishedOn = (pageId: string) => pages.find((p) => p.id === pageId)?.publishedVersionId ?? null
418
+ const asVersion = (v: PageVersion): PageVersion => ({ ...v, published: v.id === publishedOn(v.pageId) })
419
+
420
+ /**
421
+ * Write down what the page says now, exactly where the server takes a version.
422
+ *
423
+ * There is no document behind this, so the newest version's prose stands in for the live one —
424
+ * enough that restoring writes a new row saying what it restored, which is the behaviour the
425
+ * history sheet is judged on.
426
+ */
427
+ const capture = (pageId: string, kind: PageVersion['kind'], label: string | null, preview?: string) => {
428
+ const latest = versions.filter((v) => v.pageId === pageId).at(-1)
429
+ const taken = version(
430
+ ++seq,
431
+ pageId,
432
+ kind,
433
+ label,
434
+ preview ?? latest?.preview ?? found(pageId).title,
435
+ 0,
436
+ ME,
437
+ )
438
+ versions.push(taken)
439
+ return taken
440
+ }
441
+
264
442
  const theDatabase = (id: string): Database => {
265
443
  const db = databases.find((d) => d.id === id)
266
444
  if (!db) throw notFound('Database')
@@ -563,6 +741,165 @@ export function createMockQuireApi() {
563
741
  },
564
742
  },
565
743
 
744
+ versions: {
745
+ list: async ({ pageId, limit = 50 }: { pageId: string; limit?: number }) => ({
746
+ // Newest first, and the ids sort because they are minted in order — the same thing the
747
+ // server gets from ordering on a uuidv7.
748
+ items: versions
749
+ .filter((v) => v.pageId === pageId)
750
+ .sort((a, b) => (a.id < b.id ? 1 : -1))
751
+ .slice(0, limit)
752
+ .map(asVersion),
753
+ nextCursor: null,
754
+ }),
755
+
756
+ get: async ({ versionId }: { versionId: string }) => {
757
+ const found = theVersion(versionId)
758
+ return {
759
+ ...asVersion(found),
760
+ text: found.preview,
761
+ // The server renders the stored document; the escaping is the part worth keeping, since
762
+ // a screen hands this straight to a renderer.
763
+ html: `<p>${found.preview.replace(/&/g, '&amp;').replace(/</g, '&lt;')}</p>`,
764
+ }
765
+ },
766
+
767
+ create: async ({ pageId, label = null }: { pageId: string; label?: string | null }) =>
768
+ asVersion(capture(pageId, 'auto', label)),
769
+
770
+ restore: async ({ versionId }: { versionId: string }) => {
771
+ const wanted = theVersion(versionId)
772
+ // The state about to be replaced is captured first, so restoring is itself undoable — the
773
+ // reason the sheet offers it without a confirmation.
774
+ capture(wanted.pageId, 'auto', null)
775
+ const restored = capture(wanted.pageId, 'restore', wanted.label, wanted.preview)
776
+ touch(found(wanted.pageId))
777
+ return asVersion(restored)
778
+ },
779
+ },
780
+
781
+ comments: {
782
+ list: async ({
783
+ pageId,
784
+ includeResolved = false,
785
+ }: {
786
+ pageId: string
787
+ includeResolved?: boolean
788
+ }): Promise<CommentThread[]> => {
789
+ const byThread = new Map<string, Comment[]>()
790
+ for (const c of comments.filter((c) => c.pageId === pageId)) {
791
+ byThread.set(c.threadId, [...(byThread.get(c.threadId) ?? []), c])
792
+ }
793
+ const threads: CommentThread[] = []
794
+ for (const [threadId, list] of byThread) {
795
+ const ordered = [...list].sort((a, b) => (a.createdAt < b.createdAt ? -1 : 1))
796
+ // A thread whose root was deleted while replies remain is still somebody's conversation,
797
+ // so the oldest remaining comment leads it — as it does on the server.
798
+ const lead = ordered.find((c) => c.id === threadId) ?? ordered[0]
799
+ if (!lead) continue
800
+ const resolved = Boolean(lead.resolvedAt)
801
+ if (resolved && !includeResolved) continue
802
+ threads.push({
803
+ id: threadId,
804
+ root: structuredClone(lead),
805
+ replies: ordered.filter((c) => c.id !== lead.id).map((c) => structuredClone(c)),
806
+ resolved,
807
+ })
808
+ }
809
+ return threads
810
+ },
811
+
812
+ create: async (input: {
813
+ pageId: string
814
+ body: Record<string, unknown>
815
+ anchor?: CommentAnchor | null
816
+ quotedText?: string
817
+ parentId?: string | null
818
+ }) => {
819
+ const parent = input.parentId ? theComment(input.parentId) : null
820
+ const id = nextId()
821
+ const made: Comment = {
822
+ id,
823
+ workspaceId: '' as Comment['workspaceId'],
824
+ pageId: input.pageId,
825
+ parentId: parent?.id ?? null,
826
+ // A reply belongs to the thread its parent is in, never to a thread of its own.
827
+ threadId: parent?.threadId ?? id,
828
+ authorId: ME as Comment['authorId'],
829
+ body: input.body,
830
+ bodyText: flatten(input.body),
831
+ mentionIds: [],
832
+ anchor: input.anchor ?? null,
833
+ quotedText: input.quotedText ?? '',
834
+ resolvedAt: null,
835
+ resolvedBy: null,
836
+ editedAt: null,
837
+ createdAt: new Date().toISOString(),
838
+ }
839
+ comments.push(made)
840
+ return structuredClone(made)
841
+ },
842
+
843
+ update: async ({ commentId, body }: { commentId: string; body: Record<string, unknown> }) => {
844
+ const found = theComment(commentId)
845
+ found.body = body
846
+ found.bodyText = flatten(body)
847
+ found.editedAt = new Date().toISOString()
848
+ return structuredClone(found)
849
+ },
850
+
851
+ remove: async ({ commentId }: { commentId: string }) => {
852
+ const found = theComment(commentId)
853
+ comments.splice(comments.indexOf(found), 1)
854
+ return { ok: true as const }
855
+ },
856
+
857
+ resolve: async ({ commentId, resolved = true }: { commentId: string; resolved?: boolean }) => {
858
+ const lead = theComment(commentId)
859
+ // Resolving is a property of the conversation, so it is written on the comment that leads
860
+ // it and read from there — never on each reply.
861
+ lead.resolvedAt = resolved ? new Date().toISOString() : null
862
+ lead.resolvedBy = resolved ? (ME as Comment['resolvedBy']) : null
863
+ const rest = comments.filter((c) => c.threadId === lead.threadId && c.id !== lead.id)
864
+ return {
865
+ id: lead.threadId,
866
+ root: structuredClone(lead),
867
+ replies: rest.map((c) => structuredClone(c)),
868
+ resolved,
869
+ }
870
+ },
871
+ },
872
+
873
+ publishing: {
874
+ publish: async ({ pageId, label = null }: { pageId: string; label?: string | null }) => {
875
+ const row = found(pageId)
876
+ if (row.kind !== 'page')
877
+ throw Object.assign(new Error('Only a page has a published version; a live doc is always live'), {
878
+ code: 'BAD_REQUEST',
879
+ })
880
+ const taken = capture(pageId, 'publish', label)
881
+ row.publishedVersionId = taken.id
882
+ row.hasUnpublishedChanges = false
883
+ touch(row)
884
+ return strip(row)
885
+ },
886
+
887
+ revert: async ({ pageId }: { pageId: string }) => {
888
+ const row = found(pageId)
889
+ if (!row.publishedVersionId)
890
+ throw Object.assign(
891
+ new Error('This page has never been published, so there is nothing to go back to'),
892
+ { code: 'BAD_REQUEST' },
893
+ )
894
+ // The draft being discarded is kept, because discarding it should not be a way to lose an
895
+ // afternoon's writing with no way back.
896
+ capture(pageId, 'auto', null)
897
+ row.hasUnpublishedChanges = false
898
+ touch(row)
899
+ return strip(row)
900
+ },
901
+ },
902
+
566
903
  databases: {
567
904
  list: async ({ spaceId }: { spaceId: string }): Promise<DatabaseRef[]> =>
568
905
  databases
@@ -3,10 +3,12 @@ import {
3
3
  Avatar,
4
4
  Button,
5
5
  type CollabPeer,
6
+ coreApi,
6
7
  DropdownMenu,
7
8
  EmptyState,
8
9
  Icon,
9
10
  IconButton,
11
+ keys,
10
12
  navigation,
11
13
  Page,
12
14
  relativeTime,
@@ -18,6 +20,7 @@ import { getQuireApi } from '../api-instance.js'
18
20
  import CommentsPanel from '../components/CommentsPanel.svelte'
19
21
  import PageEditor from '../components/PageEditor.svelte'
20
22
  import VersionHistory from '../components/VersionHistory.svelte'
23
+ import { type CoreApi, toPerson } from '../core-api.js'
21
24
  import DatabaseView from '../database/DatabaseView.svelte'
22
25
  import { t } from '../i18n.js'
23
26
  import { canQuire } from '../permissions.js'
@@ -51,12 +54,32 @@ const workspaceSlug = $derived(navigation.workspaceSlug)
51
54
  const workspace = $derived(session.workspaces.find((w) => w.slug === workspaceSlug))
52
55
  const workspaceId = $derived(workspace?.id ?? '')
53
56
 
57
+ const core = coreApi<CoreApi>()
58
+ /*
59
+ * Who last edited this, by name.
60
+ *
61
+ * The byline drew `<Avatar id={doc.updatedBy} />` with no name — a "?" square with an empty
62
+ * accessible name — over the words "Edited 1h ago", so the one line on the page whose job is to say
63
+ * who touched it said everything except that. The same member list the margin and the person cells
64
+ * read.
65
+ */
66
+ const membersQuery = createQuery(() => ({
67
+ queryKey: keys.members(workspaceId),
68
+ enabled: Boolean(workspaceId),
69
+ queryFn: () => core.workspaces.members.list({ workspaceId, limit: 200 }),
70
+ }))
71
+
54
72
  const query = createQuery(() => ({
55
73
  queryKey: quireKeys.page(workspaceId, pageId),
56
74
  enabled: Boolean(workspaceId && pageId),
57
75
  queryFn: () => api.pages.get({ workspaceId, pageId }),
58
76
  }))
59
77
  const doc = $derived(query.data ?? null)
78
+ const editor = $derived.by(() => {
79
+ const id = doc?.updatedBy ?? null
80
+ if (!id) return null
81
+ return (membersQuery.data?.items ?? []).map(toPerson).find((p) => p.id === id) ?? null
82
+ })
60
83
 
61
84
  const editable = $derived(canQuire('pageEdit'))
62
85
  /** A database page draws a view where the prose would be, and a table needs the whole width. */
@@ -278,8 +301,12 @@ async function trash() {
278
301
  </div>
279
302
 
280
303
  <div class="byline">
281
- <Avatar id={doc.updatedBy} size={24} />
282
- <span>{t('edited_ago', { when: relativeTime(doc.updatedAt) })}</span>
304
+ <Avatar id={doc.updatedBy} name={editor?.name} src={editor?.avatarUrl ?? undefined} size={24} />
305
+ <span>
306
+ {editor
307
+ ? t('edited_ago_by', { when: relativeTime(doc.updatedAt), who: editor.name })
308
+ : t('edited_ago', { when: relativeTime(doc.updatedAt) })}
309
+ </span>
283
310
  {#if doc.kind === 'live'}
284
311
  <span class="chip"><Icon name="square-pen" size={12} /> {t('kind_live')}</span>
285
312
  {/if}
@@ -35,6 +35,27 @@ const spacesQuery = createQuery(() => ({
35
35
  }))
36
36
  const space = $derived((spacesQuery.data ?? []).find((s) => s.key === spaceKey) ?? null)
37
37
 
38
+ /*
39
+ * A space without a home page is not an empty space.
40
+ *
41
+ * This screen used to branch on `homepageId` alone and tell anyone whose space had no home page
42
+ * that it had no pages at all — while the sidebar beside it listed them. Opening a space means
43
+ * "show me this space", so with no home page chosen it opens the first top-level page, and the
44
+ * empty state is kept for the one case that is actually empty.
45
+ */
46
+ const treeQuery = createQuery(() => ({
47
+ queryKey: quireKeys.tree(workspaceId, space?.id ?? ''),
48
+ enabled: Boolean(workspaceId && space && !space.homepageId),
49
+ queryFn: () => api.pages.tree({ workspaceId, spaceId: space?.id ?? '', includeArchived: false }),
50
+ }))
51
+
52
+ /** The first top-level page by position — the same order the sidebar draws. */
53
+ const firstPageId = $derived(
54
+ (treeQuery.data ?? [])
55
+ .filter((p) => p.parentId === null && !p.archivedAt)
56
+ .sort((a, b) => (a.position < b.position ? -1 : a.position > b.position ? 1 : 0))[0]?.id ?? null,
57
+ )
58
+
38
59
  let creating = $state(false)
39
60
  async function createFirst() {
40
61
  if (!space || creating) return
@@ -66,6 +87,10 @@ async function createFirst() {
66
87
  </div>
67
88
  {:else if space.homepageId}
68
89
  <PageView {spaceKey} pageId={space.homepageId} />
90
+ {:else if treeQuery.isLoading}
91
+ <div class="pad"><Skeleton height="36px" /></div>
92
+ {:else if firstPageId}
93
+ <PageView {spaceKey} pageId={firstPageId} />
69
94
  {:else}
70
95
  <div class="pad">
71
96
  <EmptyState icon="file-text" title={t('space_empty')} description={t('space_empty_desc')}>