@heyramzi/ui 2.8.0 → 2.9.0

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 (115) hide show
  1. package/COMPONENTS.md +1 -1
  2. package/README.md +1 -1
  3. package/package.json +58 -74
  4. package/src/agents/vendor/draw.ts +4 -4
  5. package/src/canvas/headless/canvas-cleanup.test.ts +1 -1
  6. package/src/canvas/ops/ops.ts +1 -1
  7. package/src/charts/area-chart.svelte +11 -1
  8. package/src/charts/bar-chart.svelte +18 -3
  9. package/src/charts/line-chart.svelte +11 -1
  10. package/src/components/data-table/data-table.ts +4 -5
  11. package/src/components/data-table/pagination.svelte +5 -3
  12. package/src/components/docs/on-this-page.svelte +82 -5
  13. package/src/components/docs/scroll-spy.svelte.ts +6 -1
  14. package/src/components/list-page/detail-header.svelte +7 -3
  15. package/src/components/list-page/empty-state.svelte +3 -1
  16. package/src/components/list-page/filter-popover.svelte +2 -2
  17. package/src/components/list-page/rail-row.svelte +5 -2
  18. package/src/components/mark/mark.svelte +2 -1
  19. package/src/components/section-card/section-card.svelte +3 -1
  20. package/src/components/section-header/section-header.svelte +5 -5
  21. package/src/components/segmented/segment.svelte +1 -1
  22. package/src/components/table-view/table-view.svelte +6 -1
  23. package/src/components/tags-input/tags-input.svelte +1 -1
  24. package/src/components/toolbar/toolbar.svelte +1 -1
  25. package/src/editor/anchors.ts +28 -0
  26. package/src/editor/doc-blocks.ts +168 -0
  27. package/src/editor/doc-page.css +389 -0
  28. package/src/editor/editor.svelte +47 -5
  29. package/src/editor/extensions.ts +26 -2
  30. package/src/editor/markdown.ts +27 -2
  31. package/src/editor/slash-items.ts +18 -0
  32. package/src/icons/trades.ts +1 -1
  33. package/src/inbox/README.md +6 -4
  34. package/src/inbox/inbox.test.ts +1 -1
  35. package/src/inbox/index.ts +2 -2
  36. package/src/inbox/logic.ts +29 -5
  37. package/src/inbox/thread-inbox.svelte +219 -92
  38. package/src/inbox/types.ts +27 -1
  39. package/src/lib/initials.test.ts +25 -0
  40. package/src/lib/initials.ts +29 -0
  41. package/src/modules.test.ts +4 -64
  42. package/src/panel/README.md +1 -1
  43. package/src/record/README.md +1 -1
  44. package/src/record/info-list.svelte +5 -4
  45. package/src/record/next-up.svelte +1 -1
  46. package/src/record/project-list.svelte +1 -1
  47. package/src/record/steps-rail.svelte +3 -3
  48. package/src/shadcn/badge/badge.svelte +1 -1
  49. package/src/shadcn/card/card-title.svelte +1 -5
  50. package/src/shadcn/empty/empty-description.svelte +1 -1
  51. package/src/shadcn/progress/progress.svelte +8 -1
  52. package/src/shadcn/table/table-cell.svelte +10 -2
  53. package/src/status/README.md +1 -1
  54. package/src/thread/README.md +2 -0
  55. package/src/thread/index.ts +9 -1
  56. package/src/thread/logic.ts +33 -3
  57. package/src/thread/thread-body.svelte +37 -0
  58. package/src/thread/thread-events.svelte +30 -0
  59. package/src/thread/thread.svelte +74 -59
  60. package/src/thread/types.ts +21 -0
  61. package/src/{tasks → work}/README.md +11 -8
  62. package/src/{tasks → work}/detail/task-thread.svelte +8 -15
  63. package/src/work/docs/doc-editor-page.svelte +194 -0
  64. package/src/work/docs/doc-menu.svelte +333 -0
  65. package/src/work/docs/docs-demo.svelte +227 -0
  66. package/src/work/docs/edited-chip.svelte +125 -0
  67. package/src/work/docs/edited.ts +54 -0
  68. package/src/work/docs/index.ts +38 -0
  69. package/src/work/docs/labels.ts +70 -0
  70. package/src/work/docs/menu.ts +127 -0
  71. package/src/work/docs/outline.ts +60 -0
  72. package/src/{tasks → work}/gantt/gantt-group-row.svelte +1 -1
  73. package/src/{tasks → work}/inbox/inbox-card.svelte +113 -50
  74. package/src/work/inbox/inbox-logic.ts +98 -0
  75. package/src/work/inbox/inbox-trace.svelte +58 -0
  76. package/src/{tasks → work}/inbox/inbox-types.ts +2 -0
  77. package/src/{tasks → work}/index.ts +0 -10
  78. package/src/{tasks → work}/kanban-board.svelte +1 -1
  79. package/src/{tasks → work}/task-list.svelte +1 -1
  80. package/src/{tasks → work}/time/time-tracker.svelte +2 -5
  81. package/src/tasks/inbox/inbox-logic.ts +0 -21
  82. package/src/tasks/inbox/inbox-trace.svelte +0 -51
  83. /package/src/{tasks → work}/ambient.d.ts +0 -0
  84. /package/src/{tasks → work}/board-order.ts +0 -0
  85. /package/src/{tasks → work}/dates.ts +0 -0
  86. /package/src/{tasks → work}/demo.svelte +0 -0
  87. /package/src/{tasks → work}/detail/detail-demo.svelte +0 -0
  88. /package/src/{tasks → work}/detail/detail-types.ts +0 -0
  89. /package/src/{tasks → work}/detail/task-chip.svelte +0 -0
  90. /package/src/{tasks → work}/detail/task-detail.svelte +0 -0
  91. /package/src/{tasks → work}/detail/task-property.svelte +0 -0
  92. /package/src/{tasks → work}/gantt/gantt-bar.svelte +0 -0
  93. /package/src/{tasks → work}/gantt/gantt-grid.svelte +0 -0
  94. /package/src/{tasks → work}/gantt/gantt-header.svelte +0 -0
  95. /package/src/{tasks → work}/gantt/gantt-task-row.svelte +0 -0
  96. /package/src/{tasks → work}/gantt/gantt.css +0 -0
  97. /package/src/{tasks → work}/gantt/gantt.test.ts +0 -0
  98. /package/src/{tasks → work}/gantt/logic.test.ts +0 -0
  99. /package/src/{tasks → work}/gantt/logic.ts +0 -0
  100. /package/src/{tasks → work}/inbox/inbox-draft.svelte +0 -0
  101. /package/src/{tasks → work}/inbox/inbox-statuses.ts +0 -0
  102. /package/src/{tasks → work}/inbox/index.ts +0 -0
  103. /package/src/{tasks → work}/kanban-column.svelte +0 -0
  104. /package/src/{tasks → work}/kanban-drag.ts +0 -0
  105. /package/src/{tasks → work}/kanban-types.ts +0 -0
  106. /package/src/{tasks → work}/presets/sales.ts +0 -0
  107. /package/src/{tasks → work}/reorder.ts +0 -0
  108. /package/src/{tasks → work}/task-gantt.svelte +0 -0
  109. /package/src/{tasks → work}/task-types.ts +0 -0
  110. /package/src/{tasks → work}/time/duration.ts +0 -0
  111. /package/src/{tasks → work}/time/time-chip.svelte +0 -0
  112. /package/src/{tasks → work}/time/time-dot.svelte +0 -0
  113. /package/src/{tasks → work}/time/time-ring.svelte +0 -0
  114. /package/src/{tasks → work}/time/time-types.ts +0 -0
  115. /package/src/{tasks → work}/use-kanban-dnd.svelte.ts +0 -0
@@ -0,0 +1,227 @@
1
+ <script lang="ts">
2
+ // =========================
3
+ // IMPORTS
4
+ // =========================
5
+ import { Bell, FileText, LinkSimple, Star } from "phosphor-svelte";
6
+ import DocEditorPage from "./doc-editor-page.svelte";
7
+ import DocMenu from "./doc-menu.svelte";
8
+ import EditedChip from "./edited-chip.svelte";
9
+ import type { CopyKind } from "./menu.js";
10
+
11
+ // =========================
12
+ // TYPES
13
+ // =========================
14
+ type Node = Record<string, unknown>;
15
+
16
+ // =========================
17
+ // CONTENT
18
+ // =========================
19
+ const text = (value: string, marks?: Node[]): Node => ({
20
+ type: "text",
21
+ text: value,
22
+ marks,
23
+ });
24
+ const p = (...parts: (string | Node)[]): Node => ({
25
+ type: "paragraph",
26
+ content: parts.map((part) => (typeof part === "string" ? text(part) : part)),
27
+ });
28
+ const h = (level: number, value: string): Node => ({
29
+ type: "heading",
30
+ attrs: { level },
31
+ content: [text(value)],
32
+ });
33
+ const task = (value: string, checked = false): Node => ({
34
+ type: "taskItem",
35
+ attrs: { checked },
36
+ content: [p(value)],
37
+ });
38
+ const cell = (type: "tableHeader" | "tableCell", value: string): Node => ({
39
+ type,
40
+ content: [p(value)],
41
+ });
42
+ const row = (type: "tableHeader" | "tableCell", ...values: string[]): Node => ({
43
+ type: "tableRow",
44
+ content: values.map((value) => cell(type, value)),
45
+ });
46
+
47
+ const content: Node = {
48
+ type: "doc",
49
+ content: [
50
+ h(2, "Why this exists"),
51
+ p(
52
+ "The weekly report used to be copied by hand from ClickUp into a sheet. It took an hour and the numbers drifted. This doc says how the sync works, what it leaves out and what's still open.",
53
+ ),
54
+ h(2, "How it works"),
55
+ p(
56
+ "Every night a job reads the tasks changed that day and writes one row per task. It never deletes a row. A task that moves lists keeps its row and gets a new ",
57
+ text("list", [{ type: "code" }]),
58
+ " value.",
59
+ ),
60
+ h(3, "What a row holds"),
61
+ {
62
+ type: "table",
63
+ content: [
64
+ row("tableHeader", "Column", "Source", "Notes"),
65
+ row("tableCell", "Task", "name", "Linked back to ClickUp"),
66
+ row("tableCell", "Owner", "assignees[0]", "First assignee only"),
67
+ row("tableCell", "Hours", "time tracked", "Summed per day"),
68
+ ],
69
+ },
70
+ h(3, "The job"),
71
+ {
72
+ type: "codeBlock",
73
+ attrs: { language: "typescript" },
74
+ content: [
75
+ text(
76
+ 'export async function syncDay(day: string) {\n const tasks = await clickup.changedOn(day);\n const rows = tasks.map(toRow);\n await sheet.upsert(rows, { key: "id" });\n}',
77
+ ),
78
+ ],
79
+ },
80
+ h(2, "Before it ships"),
81
+ {
82
+ type: "taskList",
83
+ content: [
84
+ task("Backfill the last four weeks", true),
85
+ task("Decide what a deleted task does to its row"),
86
+ task("Give finance read access to the sheet"),
87
+ ],
88
+ },
89
+ {
90
+ type: "blockquote",
91
+ content: [
92
+ p(
93
+ "A report nobody trusts is worse than no report. Row counts get checked against ClickUp every Monday.",
94
+ ),
95
+ ],
96
+ },
97
+ h(2, "Open questions"),
98
+ p(
99
+ "Subtasks are flattened into their parent today. If finance wants hours per subtask, the key changes from the task id to the pair of ids, and the backfill runs again.",
100
+ ),
101
+ ],
102
+ };
103
+
104
+ // =========================
105
+ // STATE
106
+ // =========================
107
+ let title = $state("ClickUp to Sheets");
108
+ let favorited = $state(false);
109
+ let showAuthors = $state(false);
110
+ let editedAt = $state<Date>(new Date("2026-10-21T09:12:00"));
111
+ let note = $state("");
112
+
113
+ // =========================
114
+ // DERIVED
115
+ // =========================
116
+ let latest = $state<Node>(content);
117
+
118
+ const owner = { name: "Ramzi Tabka" };
119
+
120
+ // =========================
121
+ // FUNCTIONS
122
+ // =========================
123
+ function touched(json: Record<string, unknown>) {
124
+ latest = json;
125
+ editedAt = new Date();
126
+ }
127
+
128
+ function said(what: string) {
129
+ note = what;
130
+ }
131
+
132
+ function copied(kind: CopyKind) {
133
+ said(`Copied ${kind === "link" ? "title as link" : kind}`);
134
+ }
135
+ </script>
136
+
137
+ <!-- =========================== -->
138
+ <!-- MARKUP -->
139
+ <!-- =========================== -->
140
+ <div class="flex flex-col gap-2">
141
+ <div
142
+ class="h-[min(48rem,calc(100svh-9rem))] overflow-hidden rounded-xl border border-border bg-background"
143
+ >
144
+ <DocEditorPage
145
+ bind:title
146
+ {content}
147
+ onchange={touched}
148
+ oncopylink={() => said("Copied link to section")}
149
+ >
150
+ {#snippet breadcrumb()}
151
+ <span class="flex min-w-0 items-center gap-1.5 text-muted-foreground @max-xl/doc:hidden">
152
+ <FileText size={14} aria-hidden="true" />
153
+ <span class="truncate">Reporting</span>
154
+ </span>
155
+ <span class="px-0.5 text-muted-foreground/60 @max-xl/doc:hidden" aria-hidden="true">›</span>
156
+ <span class="flex min-w-0 items-center gap-1.5 font-medium">
157
+ <FileText size={14} class="shrink-0 text-muted-foreground" aria-hidden="true" />
158
+ <span class="truncate">{title.trim() || "New document"}</span>
159
+ </span>
160
+ {/snippet}
161
+
162
+ {#snippet menu()}
163
+ <button
164
+ type="button"
165
+ aria-label={favorited ? "Remove from favorites" : "Favorite"}
166
+ aria-pressed={favorited}
167
+ onclick={() => (favorited = !favorited)}
168
+ class="t-press inline-flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors duration-[var(--duration-fast)] ease-[var(--ease-smooth-out)] outline-none hover:bg-foreground/6 hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/50 any-pointer-coarse:size-10"
169
+ >
170
+ <Star
171
+ size={15}
172
+ weight={favorited ? "fill" : "regular"}
173
+ class={favorited ? "text-brand" : ""}
174
+ />
175
+ </button>
176
+ <DocMenu
177
+ {title}
178
+ url="https://ui.heyramzi.com/work/docs"
179
+ content={latest}
180
+ {favorited}
181
+ onmove={() => said("Move to")}
182
+ onduplicate={() => said("Duplicated")}
183
+ onrename={() => said("Rename")}
184
+ ontemplate={() => said("Template created")}
185
+ onfavorite={() => (favorited = !favorited)}
186
+ oncopy={copied}
187
+ onremind={() => said("Remind me")}
188
+ onhistory={() => said("History")}
189
+ ondelete={() => said("Deleted")}
190
+ />
191
+ {/snippet}
192
+
193
+ {#snippet meta()}
194
+ <EditedChip
195
+ {editedAt}
196
+ {owner}
197
+ lastEditor={owner}
198
+ showAuthorNames={showAuthors}
199
+ onshowauthors={(next) => (showAuthors = next)}
200
+ onhistory={() => said("History")}
201
+ />
202
+ {/snippet}
203
+
204
+ {#snippet actions()}
205
+ {@const button =
206
+ "t-press inline-flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors duration-[var(--duration-fast)] ease-[var(--ease-smooth-out)] outline-none hover:bg-foreground/6 hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/50 any-pointer-coarse:size-10"}
207
+ <button
208
+ type="button"
209
+ aria-label="Copy link"
210
+ class="{button} @max-xl/doc:hidden"
211
+ onclick={() => said("Copied URL")}
212
+ >
213
+ <LinkSimple size={15} />
214
+ </button>
215
+ <button
216
+ type="button"
217
+ aria-label="Subscribe"
218
+ class={button}
219
+ onclick={() => said("Subscribed")}
220
+ >
221
+ <Bell size={15} />
222
+ </button>
223
+ {/snippet}
224
+ </DocEditorPage>
225
+ </div>
226
+ <p class="h-4 text-xs text-muted-foreground" aria-live="polite">{note}</p>
227
+ </div>
@@ -0,0 +1,125 @@
1
+ <script lang="ts">
2
+ // =========================
3
+ // IMPORTS
4
+ // =========================
5
+ import { ClockCounterClockwise, SlidersHorizontal } from "phosphor-svelte";
6
+ import { Avatar, AvatarFallback, AvatarImage } from "../../shadcn/avatar/index.js";
7
+ import { Popover, PopoverContent, PopoverTrigger } from "../../shadcn/popover/index.js";
8
+ import { Switch } from "../../shadcn/switch/index.js";
9
+ import { initials } from "../../lib/initials.js";
10
+ import { editStamp, editedLabel, type DateInput } from "./edited.js";
11
+ import { docLabels, type DocLabels } from "./labels.js";
12
+
13
+ // =========================
14
+ // TYPES
15
+ // =========================
16
+ type Person = { name: string; image?: string | null };
17
+
18
+ interface Props {
19
+ /** When the doc last changed. Unreadable or missing leaves just "Edited". */
20
+ editedAt?: DateInput;
21
+ owner?: Person | null;
22
+ /** The last editor. Their time falls back to `editedAt`. */
23
+ lastEditor?: (Person & { at?: DateInput }) | null;
24
+ /** The switch row shows only when this is passed. */
25
+ showAuthorNames?: boolean;
26
+ onshowauthors?: (show: boolean) => void;
27
+ /** The history button shows only when this is passed. */
28
+ onhistory?: () => void;
29
+ labels?: Partial<DocLabels>;
30
+ timeZone?: string;
31
+ now?: Date;
32
+ }
33
+
34
+ // =========================
35
+ // PROPS
36
+ // =========================
37
+ let {
38
+ editedAt,
39
+ owner = null,
40
+ lastEditor = null,
41
+ showAuthorNames = false,
42
+ onshowauthors,
43
+ onhistory,
44
+ labels: labelOverrides,
45
+ timeZone,
46
+ now,
47
+ }: Props = $props();
48
+
49
+ // =========================
50
+ // DERIVED
51
+ // =========================
52
+ let t = $derived(docLabels(labelOverrides));
53
+ let chip = $derived(editedLabel(editedAt, t.edited, { timeZone, now }));
54
+ let editorStamp = $derived(editStamp(lastEditor?.at ?? editedAt, { timeZone, now }));
55
+ // Nothing to open: the chip is a plain label rather than a button that opens an empty card.
56
+ let hasDetails = $derived(!!onshowauthors || !!owner || !!lastEditor || !!onhistory);
57
+ </script>
58
+
59
+ {#snippet person(who: Person)}
60
+ <Avatar class="size-5">
61
+ {#if who.image}<AvatarImage src={who.image} alt="" />{/if}
62
+ <AvatarFallback class="text-[9px]">{initials(who.name)}</AvatarFallback>
63
+ </Avatar>
64
+ <span class="truncate text-foreground">{who.name}</span>
65
+ {/snippet}
66
+
67
+ <!-- =========================== -->
68
+ <!-- MARKUP -->
69
+ <!-- =========================== -->
70
+ {#if hasDetails}
71
+ <Popover>
72
+ <PopoverTrigger
73
+ class="t-press inline-flex h-7 items-center gap-1.5 rounded-md bg-foreground/5 px-2 @max-xl/doc:px-1.5 text-[13px] font-medium text-foreground/90 tabular-nums transition-colors duration-[var(--duration-fast)] ease-[var(--ease-smooth-out)] outline-none hover:bg-foreground/8 focus-visible:ring-3 focus-visible:ring-ring/50 aria-expanded:bg-foreground/8 any-pointer-coarse:h-10"
74
+ >
75
+ <SlidersHorizontal size={14} class="text-muted-foreground" aria-hidden="true" />
76
+ <!-- On a narrow page the glyph stands alone, so the title keeps the bar. -->
77
+ <span class="@max-xl/doc:sr-only">{chip}</span>
78
+ </PopoverTrigger>
79
+ <PopoverContent align="end" sideOffset={6} class="w-[21.5rem] gap-0 p-0 text-[13px]">
80
+ <div class="divide-y divide-border/70">
81
+ {#if onshowauthors}
82
+ <div class="flex items-center justify-between px-4 py-3">
83
+ <span class="text-muted-foreground">{t.showAuthorNames}</span>
84
+ <Switch
85
+ checked={showAuthorNames}
86
+ onCheckedChange={(next) => onshowauthors?.(next)}
87
+ aria-label={t.showAuthorNames}
88
+ />
89
+ </div>
90
+ {/if}
91
+ {#if owner}
92
+ <div class="flex items-center gap-3 px-4 py-3">
93
+ <span class="w-20 shrink-0 text-muted-foreground">{t.ownedBy}</span>
94
+ <span class="flex min-w-0 items-center gap-2">{@render person(owner)}</span>
95
+ </div>
96
+ {/if}
97
+ {#if lastEditor}
98
+ <div class="flex items-center gap-3 px-4 py-3">
99
+ <span class="w-20 shrink-0 text-muted-foreground">{t.lastEditBy}</span>
100
+ <span class="flex min-w-0 flex-1 items-center gap-2">{@render person(lastEditor)}</span>
101
+ {#if editorStamp}
102
+ <span class="shrink-0 text-xs text-muted-foreground tabular-nums">{editorStamp}</span>
103
+ {/if}
104
+ </div>
105
+ {/if}
106
+ {#if onhistory}
107
+ <div class="p-2">
108
+ <button
109
+ type="button"
110
+ onclick={onhistory}
111
+ class="t-press flex h-8 w-full items-center justify-center gap-2 rounded-lg bg-foreground/5 font-medium transition-colors duration-[var(--duration-fast)] ease-[var(--ease-smooth-out)] outline-none hover:bg-foreground/8 focus-visible:ring-3 focus-visible:ring-ring/50"
112
+ >
113
+ <ClockCounterClockwise size={14} aria-hidden="true" />
114
+ {t.showHistory}
115
+ </button>
116
+ </div>
117
+ {/if}
118
+ </div>
119
+ </PopoverContent>
120
+ </Popover>
121
+ {:else}
122
+ <span class="inline-flex h-7 items-center text-[13px] text-muted-foreground tabular-nums">
123
+ {chip}
124
+ </span>
125
+ {/if}
@@ -0,0 +1,54 @@
1
+ /** The "Edited Oct 23" chip: how a stamp reads, and what to show when it can't be read. */
2
+
3
+ export type DateInput = Date | string | number | null | undefined;
4
+
5
+ export type StampOptions = {
6
+ /** The clock, so a test doesn't depend on today. */
7
+ now?: Date;
8
+ locale?: string;
9
+ timeZone?: string;
10
+ };
11
+
12
+ export function parseStamp(value: DateInput): Date | null {
13
+ if (value === null || value === undefined || value === "") return null;
14
+ const date = value instanceof Date ? value : new Date(value);
15
+ return Number.isNaN(date.getTime()) ? null : date;
16
+ }
17
+
18
+ function yearOf(date: Date, { locale = "en-US", timeZone }: StampOptions) {
19
+ return new Intl.DateTimeFormat(locale, { year: "numeric", timeZone }).format(date);
20
+ }
21
+
22
+ /** "Oct 23", or "Oct 23, 2025" when it isn't this year. Empty when the date can't be read. */
23
+ export function shortDate(value: DateInput, options: StampOptions = {}): string {
24
+ const date = parseStamp(value);
25
+ if (!date) return "";
26
+ const { locale = "en-US", timeZone, now = new Date() } = options;
27
+ const sameYear = yearOf(date, options) === yearOf(now, options);
28
+ return new Intl.DateTimeFormat(locale, {
29
+ month: "short",
30
+ day: "numeric",
31
+ year: sameYear ? undefined : "numeric",
32
+ timeZone,
33
+ }).format(date);
34
+ }
35
+
36
+ /** "Oct 23, 14:44" for the last-edit row. Empty when the date can't be read. */
37
+ export function editStamp(value: DateInput, options: StampOptions = {}): string {
38
+ const date = parseStamp(value);
39
+ if (!date) return "";
40
+ const { locale = "en-US", timeZone } = options;
41
+ const time = new Intl.DateTimeFormat(locale, {
42
+ hour: "2-digit",
43
+ minute: "2-digit",
44
+ hour12: false,
45
+ timeZone,
46
+ }).format(date);
47
+ return `${shortDate(date, options)}, ${time}`;
48
+ }
49
+
50
+ /** The chip's text. `label` is the word ("Edited"); a bad date leaves only the word. */
51
+ export function editedLabel(value: DateInput, label = "Edited", options: StampOptions = {}) {
52
+ const day = shortDate(value, options);
53
+ return day ? `${label} ${day}` : label;
54
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * The document surface: a page to write one, the "Edited" chip, the `…` menu and the outline
3
+ * logic behind the edge ticks. The app supplies the data and every handler.
4
+ */
5
+ export { default as DocEditorPage } from "./doc-editor-page.svelte";
6
+ export { default as DocMenu } from "./doc-menu.svelte";
7
+ export { default as EditedChip } from "./edited-chip.svelte";
8
+ export { DEFAULT_DOC_LABELS, docLabels, type DocLabels } from "./labels.js";
9
+ export {
10
+ anchorIds,
11
+ headingsOf,
12
+ outlineOf,
13
+ outlineOfDoc,
14
+ slugify,
15
+ type FoundHeading,
16
+ type OutlineHeading,
17
+ type OutlineLevel,
18
+ } from "./outline.js";
19
+ export {
20
+ editStamp,
21
+ editedLabel,
22
+ parseStamp,
23
+ shortDate,
24
+ type DateInput,
25
+ type StampOptions,
26
+ } from "./edited.js";
27
+ export {
28
+ copyOptions,
29
+ filterMenu,
30
+ menuItems,
31
+ shortcutLabel,
32
+ shortcutTarget,
33
+ type CopyKind,
34
+ type CopyOption,
35
+ type MenuId,
36
+ type MenuItem,
37
+ type Shortcut,
38
+ } from "./menu.js";
@@ -0,0 +1,70 @@
1
+ /** Every word the docs surface prints. The app passes a partial to localise it. */
2
+ export type DocLabels = {
3
+ titlePlaceholder: string;
4
+ untitled: string;
5
+ edited: string;
6
+ showAuthorNames: string;
7
+ ownedBy: string;
8
+ lastEditBy: string;
9
+ showHistory: string;
10
+ menu: string;
11
+ filter: string;
12
+ noResults: string;
13
+ moveTo: string;
14
+ duplicate: string;
15
+ rename: string;
16
+ template: string;
17
+ favorite: string;
18
+ unfavorite: string;
19
+ copy: string;
20
+ copyUrl: string;
21
+ copyTitle: string;
22
+ copyTitleAsLink: string;
23
+ copyMarkdown: string;
24
+ remind: string;
25
+ history: string;
26
+ delete: string;
27
+ deleteTitle: (title: string) => string;
28
+ deleteDetail: string;
29
+ cancel: string;
30
+ back: string;
31
+ outline: string;
32
+ copyLink: string;
33
+ };
34
+
35
+ export const DEFAULT_DOC_LABELS: DocLabels = {
36
+ titlePlaceholder: "New document",
37
+ untitled: "Untitled",
38
+ edited: "Edited",
39
+ showAuthorNames: "Show author names",
40
+ ownedBy: "Owned by",
41
+ lastEditBy: "Last edit by",
42
+ showHistory: "Show document history",
43
+ menu: "Document actions",
44
+ filter: "Filter…",
45
+ noResults: "Nothing matches",
46
+ moveTo: "Move to",
47
+ duplicate: "Duplicate",
48
+ rename: "Rename",
49
+ template: "New template from document",
50
+ favorite: "Favorite",
51
+ unfavorite: "Remove from favorites",
52
+ copy: "Copy",
53
+ copyUrl: "Copy URL",
54
+ copyTitle: "Copy title",
55
+ copyTitleAsLink: "Copy title as link",
56
+ copyMarkdown: "Copy content as Markdown",
57
+ remind: "Remind me",
58
+ history: "Show document history",
59
+ delete: "Delete",
60
+ deleteTitle: (title) => `Delete "${title}"`,
61
+ deleteDetail: "This can't be undone.",
62
+ cancel: "Cancel",
63
+ back: "Back",
64
+ outline: "Outline",
65
+ copyLink: "Copy link",
66
+ };
67
+
68
+ export function docLabels(partial?: Partial<DocLabels>): DocLabels {
69
+ return { ...DEFAULT_DOC_LABELS, ...partial };
70
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * The `…` menu as data: which items exist, which show, how a filter reads them and
3
+ * which keys fire them. The component only draws what this returns.
4
+ */
5
+ import { tiptapToMarkdown } from "../../editor/markdown.js";
6
+ import type { DocLabels } from "./labels.js";
7
+
8
+ export type MenuId =
9
+ | "move"
10
+ | "duplicate"
11
+ | "rename"
12
+ | "template"
13
+ | "favorite"
14
+ | "copy"
15
+ | "remind"
16
+ | "history"
17
+ | "delete";
18
+
19
+ export type Shortcut = { key: string; shift?: boolean; alt?: boolean };
20
+
21
+ export type MenuItem = {
22
+ id: MenuId;
23
+ label: string;
24
+ shortcut?: Shortcut;
25
+ /** Opens a second list instead of firing. */
26
+ submenu?: boolean;
27
+ danger?: boolean;
28
+ /** Items in one group sit together; a hairline separates groups. */
29
+ group: number;
30
+ };
31
+
32
+ export const SHORTCUTS: Partial<Record<MenuId, Shortcut>> = {
33
+ move: { key: "P", shift: true },
34
+ rename: { key: "R", shift: true },
35
+ favorite: { key: "F", alt: true },
36
+ remind: { key: "H", shift: true },
37
+ };
38
+
39
+ /** "⇧P", "⌥F". */
40
+ export function shortcutLabel(shortcut: Shortcut): string {
41
+ return `${shortcut.shift ? "⇧" : ""}${shortcut.alt ? "⌥" : ""}${shortcut.key.toUpperCase()}`;
42
+ }
43
+
44
+ /** Which callbacks the host passed. An item with no callback never shows. */
45
+ export type MenuAvailable = Partial<Record<MenuId, boolean>>;
46
+
47
+ export function menuItems(
48
+ available: MenuAvailable,
49
+ labels: DocLabels,
50
+ options: { favorited?: boolean } = {},
51
+ ): MenuItem[] {
52
+ const all: MenuItem[] = [
53
+ { id: "move", label: labels.moveTo, group: 0 },
54
+ { id: "duplicate", label: labels.duplicate, group: 0 },
55
+ { id: "rename", label: labels.rename, group: 0 },
56
+ { id: "template", label: labels.template, group: 0 },
57
+ { id: "favorite", label: options.favorited ? labels.unfavorite : labels.favorite, group: 1 },
58
+ { id: "copy", label: labels.copy, submenu: true, group: 1 },
59
+ { id: "remind", label: labels.remind, group: 1 },
60
+ { id: "history", label: labels.history, group: 2 },
61
+ { id: "delete", label: labels.delete, danger: true, group: 2 },
62
+ ];
63
+ return all
64
+ .filter((item) => available[item.id])
65
+ .map((item) => Object.assign(item, { shortcut: SHORTCUTS[item.id] }));
66
+ }
67
+
68
+ /** Case-insensitive, every typed word must appear. An empty query keeps everything. */
69
+ export function filterMenu<T extends { label: string }>(items: T[], query: string): T[] {
70
+ const words = query.toLowerCase().split(/\s+/).filter(Boolean);
71
+ if (words.length === 0) return items;
72
+ return items.filter((item) => {
73
+ const label = item.label.toLowerCase();
74
+ return words.every((word) => label.includes(word));
75
+ });
76
+ }
77
+
78
+ /** The item a key press fires, or null. `code` is the physical key, since Option+F types "ƒ". */
79
+ export function shortcutTarget(
80
+ event: { code: string; shiftKey: boolean; altKey: boolean; metaKey: boolean; ctrlKey: boolean },
81
+ items: MenuItem[],
82
+ ): MenuId | null {
83
+ if (event.metaKey || event.ctrlKey) return null;
84
+ const hit = items.find(
85
+ (item) =>
86
+ item.shortcut &&
87
+ event.code === `Key${item.shortcut.key.toUpperCase()}` &&
88
+ !!item.shortcut.shift === event.shiftKey &&
89
+ !!item.shortcut.alt === event.altKey,
90
+ );
91
+ return hit?.id ?? null;
92
+ }
93
+
94
+ export type CopyKind = "url" | "title" | "link" | "markdown";
95
+
96
+ export type CopyOption = { kind: CopyKind; label: string; text: string };
97
+
98
+ /** The Copy submenu. A kind with nothing to copy is left out: no URL, no content. */
99
+ export function copyOptions(
100
+ source: { title: string; url?: string; content?: unknown },
101
+ labels: DocLabels,
102
+ ): CopyOption[] {
103
+ const title = source.title.trim() || labels.untitled;
104
+ const options: CopyOption[] = [];
105
+ if (source.url) options.push({ kind: "url", label: labels.copyUrl, text: source.url });
106
+ options.push({ kind: "title", label: labels.copyTitle, text: title });
107
+ if (source.url) {
108
+ options.push({
109
+ kind: "link",
110
+ label: labels.copyTitleAsLink,
111
+ text: `[${title.replace(/([[\]\\])/g, "\\$1")}](${source.url})`,
112
+ });
113
+ }
114
+ if (source.content) {
115
+ options.push({
116
+ kind: "markdown",
117
+ label: labels.copyMarkdown,
118
+ text: tiptapToMarkdown(source.content),
119
+ });
120
+ }
121
+ return options;
122
+ }
123
+
124
+ /** What the delete confirm calls the doc. */
125
+ export function deleteName(title: string, labels: DocLabels): string {
126
+ return title.trim() || labels.untitled;
127
+ }