@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.
- package/COMPONENTS.md +1 -1
- package/README.md +1 -1
- package/package.json +58 -74
- package/src/agents/vendor/draw.ts +4 -4
- package/src/canvas/headless/canvas-cleanup.test.ts +1 -1
- package/src/canvas/ops/ops.ts +1 -1
- package/src/charts/area-chart.svelte +11 -1
- package/src/charts/bar-chart.svelte +18 -3
- package/src/charts/line-chart.svelte +11 -1
- package/src/components/data-table/data-table.ts +4 -5
- package/src/components/data-table/pagination.svelte +5 -3
- package/src/components/docs/on-this-page.svelte +82 -5
- package/src/components/docs/scroll-spy.svelte.ts +6 -1
- package/src/components/list-page/detail-header.svelte +7 -3
- package/src/components/list-page/empty-state.svelte +3 -1
- package/src/components/list-page/filter-popover.svelte +2 -2
- package/src/components/list-page/rail-row.svelte +5 -2
- package/src/components/mark/mark.svelte +2 -1
- package/src/components/section-card/section-card.svelte +3 -1
- package/src/components/section-header/section-header.svelte +5 -5
- package/src/components/segmented/segment.svelte +1 -1
- package/src/components/table-view/table-view.svelte +6 -1
- package/src/components/tags-input/tags-input.svelte +1 -1
- package/src/components/toolbar/toolbar.svelte +1 -1
- package/src/editor/anchors.ts +28 -0
- package/src/editor/doc-blocks.ts +168 -0
- package/src/editor/doc-page.css +389 -0
- package/src/editor/editor.svelte +47 -5
- package/src/editor/extensions.ts +26 -2
- package/src/editor/markdown.ts +27 -2
- package/src/editor/slash-items.ts +18 -0
- package/src/icons/trades.ts +1 -1
- package/src/inbox/README.md +6 -4
- package/src/inbox/inbox.test.ts +1 -1
- package/src/inbox/index.ts +2 -2
- package/src/inbox/logic.ts +29 -5
- package/src/inbox/thread-inbox.svelte +219 -92
- package/src/inbox/types.ts +27 -1
- package/src/lib/initials.test.ts +25 -0
- package/src/lib/initials.ts +29 -0
- package/src/modules.test.ts +4 -64
- package/src/panel/README.md +1 -1
- package/src/record/README.md +1 -1
- package/src/record/info-list.svelte +5 -4
- package/src/record/next-up.svelte +1 -1
- package/src/record/project-list.svelte +1 -1
- package/src/record/steps-rail.svelte +3 -3
- package/src/shadcn/badge/badge.svelte +1 -1
- package/src/shadcn/card/card-title.svelte +1 -5
- package/src/shadcn/empty/empty-description.svelte +1 -1
- package/src/shadcn/progress/progress.svelte +8 -1
- package/src/shadcn/table/table-cell.svelte +10 -2
- package/src/status/README.md +1 -1
- package/src/thread/README.md +2 -0
- package/src/thread/index.ts +9 -1
- package/src/thread/logic.ts +33 -3
- package/src/thread/thread-body.svelte +37 -0
- package/src/thread/thread-events.svelte +30 -0
- package/src/thread/thread.svelte +74 -59
- package/src/thread/types.ts +21 -0
- package/src/{tasks → work}/README.md +11 -8
- package/src/{tasks → work}/detail/task-thread.svelte +8 -15
- package/src/work/docs/doc-editor-page.svelte +194 -0
- package/src/work/docs/doc-menu.svelte +333 -0
- package/src/work/docs/docs-demo.svelte +227 -0
- package/src/work/docs/edited-chip.svelte +125 -0
- package/src/work/docs/edited.ts +54 -0
- package/src/work/docs/index.ts +38 -0
- package/src/work/docs/labels.ts +70 -0
- package/src/work/docs/menu.ts +127 -0
- package/src/work/docs/outline.ts +60 -0
- package/src/{tasks → work}/gantt/gantt-group-row.svelte +1 -1
- package/src/{tasks → work}/inbox/inbox-card.svelte +113 -50
- package/src/work/inbox/inbox-logic.ts +98 -0
- package/src/work/inbox/inbox-trace.svelte +58 -0
- package/src/{tasks → work}/inbox/inbox-types.ts +2 -0
- package/src/{tasks → work}/index.ts +0 -10
- package/src/{tasks → work}/kanban-board.svelte +1 -1
- package/src/{tasks → work}/task-list.svelte +1 -1
- package/src/{tasks → work}/time/time-tracker.svelte +2 -5
- package/src/tasks/inbox/inbox-logic.ts +0 -21
- package/src/tasks/inbox/inbox-trace.svelte +0 -51
- /package/src/{tasks → work}/ambient.d.ts +0 -0
- /package/src/{tasks → work}/board-order.ts +0 -0
- /package/src/{tasks → work}/dates.ts +0 -0
- /package/src/{tasks → work}/demo.svelte +0 -0
- /package/src/{tasks → work}/detail/detail-demo.svelte +0 -0
- /package/src/{tasks → work}/detail/detail-types.ts +0 -0
- /package/src/{tasks → work}/detail/task-chip.svelte +0 -0
- /package/src/{tasks → work}/detail/task-detail.svelte +0 -0
- /package/src/{tasks → work}/detail/task-property.svelte +0 -0
- /package/src/{tasks → work}/gantt/gantt-bar.svelte +0 -0
- /package/src/{tasks → work}/gantt/gantt-grid.svelte +0 -0
- /package/src/{tasks → work}/gantt/gantt-header.svelte +0 -0
- /package/src/{tasks → work}/gantt/gantt-task-row.svelte +0 -0
- /package/src/{tasks → work}/gantt/gantt.css +0 -0
- /package/src/{tasks → work}/gantt/gantt.test.ts +0 -0
- /package/src/{tasks → work}/gantt/logic.test.ts +0 -0
- /package/src/{tasks → work}/gantt/logic.ts +0 -0
- /package/src/{tasks → work}/inbox/inbox-draft.svelte +0 -0
- /package/src/{tasks → work}/inbox/inbox-statuses.ts +0 -0
- /package/src/{tasks → work}/inbox/index.ts +0 -0
- /package/src/{tasks → work}/kanban-column.svelte +0 -0
- /package/src/{tasks → work}/kanban-drag.ts +0 -0
- /package/src/{tasks → work}/kanban-types.ts +0 -0
- /package/src/{tasks → work}/presets/sales.ts +0 -0
- /package/src/{tasks → work}/reorder.ts +0 -0
- /package/src/{tasks → work}/task-gantt.svelte +0 -0
- /package/src/{tasks → work}/task-types.ts +0 -0
- /package/src/{tasks → work}/time/duration.ts +0 -0
- /package/src/{tasks → work}/time/time-chip.svelte +0 -0
- /package/src/{tasks → work}/time/time-dot.svelte +0 -0
- /package/src/{tasks → work}/time/time-ring.svelte +0 -0
- /package/src/{tasks → work}/time/time-types.ts +0 -0
- /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
|
+
}
|