@hearthlog/shell 0.0.0-stage → 0.1.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/LICENSE +235 -0
- package/package.json +51 -3
- package/src/app.template.html +184 -0
- package/src/components/ai/DecisionReviewSheet.svelte +226 -0
- package/src/components/ai/SuggestButton.svelte +23 -0
- package/src/components/ai/SuggestionChip.svelte +13 -0
- package/src/components/ai/SuggestionMark.svelte +15 -0
- package/src/components/calendar/CalendarFeedsDrawer.svelte +414 -0
- package/src/components/calendar/CalendarMonthSummary.svelte +122 -0
- package/src/components/calendar/calendar-weeks.svelte.ts +27 -0
- package/src/components/calendar/source-icons.ts +19 -0
- package/src/components/data-table/DataTableValue.svelte +14 -0
- package/src/components/data-table/EntityActionsCell.svelte +36 -0
- package/src/components/data-table/EntityRefsCell.svelte +58 -0
- package/src/components/data-table/EntityValue.svelte +42 -0
- package/src/components/data-table/entity-columns.ts +87 -0
- package/src/components/data-table/sqlite-columns.ts +42 -0
- package/src/components/entities/CalendarSourceFields.svelte +149 -0
- package/src/components/history/ChangeRecord.svelte +30 -0
- package/src/components/history/ForkComposer.svelte +174 -0
- package/src/components/history/HistoryScrubber.svelte +245 -0
- package/src/components/history/LogHistory.svelte +334 -0
- package/src/components/history/LogMap.svelte +293 -0
- package/src/components/history/LogTree.svelte +124 -0
- package/src/components/layout/AccountMenu.svelte +111 -0
- package/src/components/layout/AppearanceOptions.svelte +78 -0
- package/src/components/layout/BrandWordmark.svelte +12 -0
- package/src/components/layout/ErrorToast.svelte +34 -0
- package/src/components/layout/LogCreateDrawer.svelte +111 -0
- package/src/components/layout/LogPanel.svelte +225 -0
- package/src/components/layout/LogStatusChip.svelte +137 -0
- package/src/components/layout/MobilePageAction.svelte +44 -0
- package/src/components/layout/MovedBanner.svelte +51 -0
- package/src/components/layout/NavBar.svelte +603 -0
- package/src/components/layout/NavFlyout.svelte +132 -0
- package/src/components/layout/NavItem.svelte +36 -0
- package/src/components/layout/NavMoreDialog.svelte +179 -0
- package/src/components/layout/PageDataStatus.svelte +36 -0
- package/src/components/layout/PhoneGlance.svelte +96 -0
- package/src/components/layout/SidebarResizer.svelte +122 -0
- package/src/components/layout/SyncIndicator.svelte +24 -0
- package/src/components/layout/feature-icons.ts +24 -0
- package/src/components/layout/nav-indicator.ts +128 -0
- package/src/components/layout/sidebar-size.ts +31 -0
- package/src/components/sync/SyncIssuesDrawer.svelte +149 -0
- package/src/components/sync/SyncStateBadge.svelte +17 -0
- package/src/pages/calendar/Page.svelte +1341 -0
- package/src/pages/calendar/load.ts +77 -0
- package/src/pages/developer/DatabaseBrowser.svelte +325 -0
- package/src/pages/developer/Event.svelte +51 -0
- package/src/pages/developer/EventLogManager.svelte +268 -0
- package/src/pages/developer/IndexedDbStats.svelte +93 -0
- package/src/pages/developer/LedgerAdmin.svelte +877 -0
- package/src/pages/developer/LocalSqliteStats.svelte +56 -0
- package/src/pages/developer/Page.svelte +590 -0
- package/src/pages/developer/PendingWorkImport.svelte +49 -0
- package/src/pages/developer/PerformanceDashboard.svelte +262 -0
- package/src/pages/developer/StackInfo.svelte +117 -0
- package/src/pages/developer/SyncLog.svelte +188 -0
- package/src/pages/developer/load.ts +28 -0
- package/src/pages/entities/Page.svelte +1069 -0
- package/src/pages/entities/load.ts +18 -0
- package/src/pages/entity/Page.svelte +655 -0
- package/src/pages/entity/load.ts +79 -0
- package/src/pages/error/ErrorPage.svelte +147 -0
- package/src/pages/layout/Layout.svelte +597 -0
- package/src/pages/layout/load.ts +28 -0
- package/src/pages/redirects/history.ts +20 -0
- package/src/pages/redirects/start.ts +11 -0
- package/src/pages/redirects/today.ts +9 -0
- package/src/service-worker/index.ts +108 -0
- package/src/service-worker/offline-bundle.ts +116 -0
- package/src/service-worker/virtual.d.ts +3 -0
- package/src/styles/index.css +11 -0
- package/src/styles/shell.css +296 -0
- package/static/_headers +4 -0
- package/static/favicon.ico +0 -0
- package/static/offline.html +44 -0
- package/static/replay/sw.js +2 -0
- package/README.md +0 -3
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { ReferencedByResult } from "@hearthlog/runtime/event/reference-lookup.js";
|
|
3
|
+
|
|
4
|
+
let {
|
|
5
|
+
refs,
|
|
6
|
+
entityHref,
|
|
7
|
+
}: {
|
|
8
|
+
refs?: ReferencedByResult | null;
|
|
9
|
+
entityHref: (name: string) => string;
|
|
10
|
+
} = $props();
|
|
11
|
+
</script>
|
|
12
|
+
|
|
13
|
+
{#if refs && refs.totalCount > 0}
|
|
14
|
+
<div class="group/refs relative inline-block">
|
|
15
|
+
<span
|
|
16
|
+
class="cursor-help rounded-full bg-[var(--stor-accent-soft)] px-2.5 py-1 text-[10.5px] font-bold uppercase tracking-wide text-[var(--stor-accent-strong)]"
|
|
17
|
+
>
|
|
18
|
+
{refs.totalCount} ref{refs.totalCount !== 1 ? "s" : ""}
|
|
19
|
+
</span>
|
|
20
|
+
<div
|
|
21
|
+
class="absolute bottom-full left-0 z-20 mb-2 hidden group-hover/refs:block"
|
|
22
|
+
>
|
|
23
|
+
<div
|
|
24
|
+
class="stor-menu min-w-[180px] border border-[var(--stor-border)] p-3 text-xs"
|
|
25
|
+
>
|
|
26
|
+
<div class="mb-2 font-bold text-[var(--stor-ink)]">
|
|
27
|
+
Referenced by:
|
|
28
|
+
</div>
|
|
29
|
+
{#each Object.entries(refs.byType) as [typeName, entities]}
|
|
30
|
+
<div class="mb-1.5">
|
|
31
|
+
<span class="font-semibold text-[var(--stor-accent-strong)]"
|
|
32
|
+
>{typeName}</span
|
|
33
|
+
>
|
|
34
|
+
<ul class="mt-0.5 pl-2">
|
|
35
|
+
{#each entities.slice(0, 5) as ref}
|
|
36
|
+
<li class="text-[var(--stor-muted-text)]">
|
|
37
|
+
<a
|
|
38
|
+
href={entityHref(ref.entityType)}
|
|
39
|
+
class="transition hover:text-[var(--stor-ink)] hover:underline"
|
|
40
|
+
>
|
|
41
|
+
{ref.entityLabel}
|
|
42
|
+
</a>
|
|
43
|
+
</li>
|
|
44
|
+
{/each}
|
|
45
|
+
{#if entities.length > 5}
|
|
46
|
+
<li class="italic text-[var(--stor-muted-text)]">
|
|
47
|
+
+{entities.length - 5} more
|
|
48
|
+
</li>
|
|
49
|
+
{/if}
|
|
50
|
+
</ul>
|
|
51
|
+
</div>
|
|
52
|
+
{/each}
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
{:else}
|
|
57
|
+
<span class="text-xs text-[var(--stor-muted-text)]">—</span>
|
|
58
|
+
{/if}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { DynamicFieldDefinition } from "@hearthlog/runtime/event/meta-entity-types.js";
|
|
3
|
+
|
|
4
|
+
let {
|
|
5
|
+
value,
|
|
6
|
+
field,
|
|
7
|
+
referenceLabel = null,
|
|
8
|
+
}: {
|
|
9
|
+
value: unknown;
|
|
10
|
+
field: DynamicFieldDefinition;
|
|
11
|
+
referenceLabel?: string | null;
|
|
12
|
+
} = $props();
|
|
13
|
+
|
|
14
|
+
const text = $derived.by(() => {
|
|
15
|
+
if (value === null || value === undefined) return "-";
|
|
16
|
+
if (field.type === "boolean") return value ? "✓" : "✗";
|
|
17
|
+
if (field.type === "datetime" && value) {
|
|
18
|
+
try {
|
|
19
|
+
return new Date(value as string).toLocaleString();
|
|
20
|
+
} catch {
|
|
21
|
+
return String(value);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
if (field.type === "reference" && referenceLabel) return referenceLabel;
|
|
25
|
+
return String(value);
|
|
26
|
+
});
|
|
27
|
+
const truthy = $derived(Boolean(value));
|
|
28
|
+
</script>
|
|
29
|
+
|
|
30
|
+
{#if field.type === "boolean"}
|
|
31
|
+
<span
|
|
32
|
+
class="rounded-full px-2 py-0.5 text-[10.5px] font-bold uppercase {truthy
|
|
33
|
+
? 'bg-[var(--stor-positive-soft)] text-[var(--stor-positive)]'
|
|
34
|
+
: 'bg-[var(--stor-surface-muted)] text-[var(--stor-muted-text)]'}"
|
|
35
|
+
>
|
|
36
|
+
{text}
|
|
37
|
+
</span>
|
|
38
|
+
{:else if field.type === "number"}
|
|
39
|
+
<span class="font-mono font-medium text-[var(--stor-ink)]">{text}</span>
|
|
40
|
+
{:else}
|
|
41
|
+
<span class="whitespace-pre-wrap break-words">{text}</span>
|
|
42
|
+
{/if}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { DynamicFieldDefinition } from "@hearthlog/runtime/event/meta-entity-types.js";
|
|
2
|
+
import type { ReferencedByResult } from "@hearthlog/runtime/event/reference-lookup.js";
|
|
3
|
+
import { renderComponent } from "@tanstack/svelte-table";
|
|
4
|
+
import DataTableHeader from "@hearthlog/ui/components/data-table/DataTableHeader.svelte";
|
|
5
|
+
import EntityActionsCell from "./EntityActionsCell.svelte";
|
|
6
|
+
import EntityRefsCell from "./EntityRefsCell.svelte";
|
|
7
|
+
import EntityValue from "./EntityValue.svelte";
|
|
8
|
+
import { createAppColumnHelper } from "@hearthlog/ui/components/data-table/table.js";
|
|
9
|
+
import type { DataTableColumn } from "@hearthlog/ui/components/data-table/types.js";
|
|
10
|
+
|
|
11
|
+
export type DataTableEntityColumn = DataTableColumn;
|
|
12
|
+
|
|
13
|
+
export interface EntityTableCallbacks {
|
|
14
|
+
getReferencedBy: (id: string) => ReferencedByResult | null;
|
|
15
|
+
getReferenceLabel: (fieldName: string, value: unknown) => string | null;
|
|
16
|
+
entityHref: (name: string) => string;
|
|
17
|
+
onEdit: (row: Record<string, unknown>) => void;
|
|
18
|
+
onDelete: (id: string) => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function describeEntityField(field: DynamicFieldDefinition): string {
|
|
22
|
+
const parts: string[] = [field.type];
|
|
23
|
+
if (field.required) parts.push("required");
|
|
24
|
+
if (field.referenceTo) parts.push(`→ ${field.referenceTo}`);
|
|
25
|
+
else if (field.enum?.length) parts.push(`${field.enum.length} options`);
|
|
26
|
+
return parts.join(" · ");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const columnHelper = createAppColumnHelper<Record<string, unknown>>();
|
|
30
|
+
|
|
31
|
+
export function toEntityTableColumns(
|
|
32
|
+
fields: DynamicFieldDefinition[],
|
|
33
|
+
callbacks: EntityTableCallbacks,
|
|
34
|
+
): DataTableColumn[] {
|
|
35
|
+
const dataColumns = fields.map((field) =>
|
|
36
|
+
columnHelper.accessor((row) => row[field.name], {
|
|
37
|
+
id: field.name,
|
|
38
|
+
enableSorting: true,
|
|
39
|
+
header: (headerContext) =>
|
|
40
|
+
renderComponent(DataTableHeader, {
|
|
41
|
+
name: field.name,
|
|
42
|
+
subtitle: describeEntityField(field),
|
|
43
|
+
context: headerContext,
|
|
44
|
+
}),
|
|
45
|
+
cell: (info) =>
|
|
46
|
+
renderComponent(EntityValue, {
|
|
47
|
+
value: info.getValue(),
|
|
48
|
+
field,
|
|
49
|
+
referenceLabel: callbacks.getReferenceLabel(
|
|
50
|
+
field.name,
|
|
51
|
+
info.getValue(),
|
|
52
|
+
),
|
|
53
|
+
}),
|
|
54
|
+
}),
|
|
55
|
+
);
|
|
56
|
+
const refsColumn = columnHelper.display({
|
|
57
|
+
id: "__refs",
|
|
58
|
+
enableSorting: false,
|
|
59
|
+
header: () =>
|
|
60
|
+
renderComponent(DataTableHeader, {
|
|
61
|
+
name: "References",
|
|
62
|
+
}),
|
|
63
|
+
cell: (info) =>
|
|
64
|
+
renderComponent(EntityRefsCell, {
|
|
65
|
+
refs: callbacks.getReferencedBy(
|
|
66
|
+
String(info.row.original.id),
|
|
67
|
+
),
|
|
68
|
+
entityHref: callbacks.entityHref,
|
|
69
|
+
}),
|
|
70
|
+
});
|
|
71
|
+
const actionsColumn = columnHelper.display({
|
|
72
|
+
id: "__actions",
|
|
73
|
+
enableSorting: false,
|
|
74
|
+
header: () =>
|
|
75
|
+
renderComponent(DataTableHeader, {
|
|
76
|
+
name: "Actions",
|
|
77
|
+
align: "right",
|
|
78
|
+
}),
|
|
79
|
+
cell: (info) =>
|
|
80
|
+
renderComponent(EntityActionsCell, {
|
|
81
|
+
onEdit: () => callbacks.onEdit(info.row.original),
|
|
82
|
+
onDelete: () =>
|
|
83
|
+
callbacks.onDelete(String(info.row.original.id)),
|
|
84
|
+
}),
|
|
85
|
+
});
|
|
86
|
+
return columnHelper.columns([...dataColumns, refsColumn, actionsColumn]);
|
|
87
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { RawSqliteColumn } from "@hearthlog/runtime/db/raw-browser.js";
|
|
2
|
+
import { presentRawSqliteValue } from "@hearthlog/runtime/db/raw-browser.js";
|
|
3
|
+
import { renderComponent } from "@tanstack/svelte-table";
|
|
4
|
+
import DataTableHeader from "@hearthlog/ui/components/data-table/DataTableHeader.svelte";
|
|
5
|
+
import DataTableValue from "./DataTableValue.svelte";
|
|
6
|
+
import { createAppColumnHelper } from "@hearthlog/ui/components/data-table/table.js";
|
|
7
|
+
import type { DataTableColumn } from "@hearthlog/ui/components/data-table/types.js";
|
|
8
|
+
|
|
9
|
+
export function describeSqliteColumn(column: RawSqliteColumn): string {
|
|
10
|
+
const parts = [column.dataType];
|
|
11
|
+
if (column.primaryKeyPosition > 0) {
|
|
12
|
+
parts.push(`PK ${column.primaryKeyPosition}`);
|
|
13
|
+
} else if (column.notNull) {
|
|
14
|
+
parts.push("NOT NULL");
|
|
15
|
+
}
|
|
16
|
+
return parts.join(" · ");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const columnHelper = createAppColumnHelper<Record<string, unknown>>();
|
|
20
|
+
|
|
21
|
+
export function toSqliteTableColumns(
|
|
22
|
+
columns: RawSqliteColumn[],
|
|
23
|
+
): DataTableColumn[] {
|
|
24
|
+
return columnHelper.columns(
|
|
25
|
+
columns.map((column) =>
|
|
26
|
+
columnHelper.accessor((row) => row[column.name], {
|
|
27
|
+
id: column.name,
|
|
28
|
+
enableSorting: true,
|
|
29
|
+
header: (headerContext) =>
|
|
30
|
+
renderComponent(DataTableHeader, {
|
|
31
|
+
name: column.name,
|
|
32
|
+
subtitle: describeSqliteColumn(column),
|
|
33
|
+
context: headerContext,
|
|
34
|
+
}),
|
|
35
|
+
cell: (info) =>
|
|
36
|
+
renderComponent(DataTableValue, {
|
|
37
|
+
presented: presentRawSqliteValue(info.getValue()),
|
|
38
|
+
}),
|
|
39
|
+
}),
|
|
40
|
+
),
|
|
41
|
+
);
|
|
42
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import Checkbox from "@hearthlog/ui/components/ui/Checkbox.svelte";
|
|
3
|
+
import Field from "@hearthlog/ui/components/ui/Field.svelte";
|
|
4
|
+
import Input from "@hearthlog/ui/components/ui/Input.svelte";
|
|
5
|
+
import Select from "@hearthlog/ui/components/ui/Select.svelte";
|
|
6
|
+
import type { DynamicFieldDefinition } from "@hearthlog/runtime/event/meta-entity-types.js";
|
|
7
|
+
import {
|
|
8
|
+
calendarDraftError,
|
|
9
|
+
type CalendarSourceDraft,
|
|
10
|
+
} from "@hearthlog/runtime/calendar/draft.js";
|
|
11
|
+
|
|
12
|
+
let {
|
|
13
|
+
fields,
|
|
14
|
+
draft = $bindable(),
|
|
15
|
+
defaultLabel = "",
|
|
16
|
+
}: {
|
|
17
|
+
fields: readonly DynamicFieldDefinition[];
|
|
18
|
+
draft: CalendarSourceDraft;
|
|
19
|
+
defaultLabel?: string;
|
|
20
|
+
} = $props();
|
|
21
|
+
|
|
22
|
+
const dateOptions = $derived.by(() => {
|
|
23
|
+
const options = fields
|
|
24
|
+
.filter((field) => field.type === "datetime")
|
|
25
|
+
.map((field) => ({ value: field.name, label: field.name }));
|
|
26
|
+
if (
|
|
27
|
+
draft.dateField &&
|
|
28
|
+
!options.some((option) => option.value === draft.dateField)
|
|
29
|
+
) {
|
|
30
|
+
options.push({ value: draft.dateField, label: draft.dateField });
|
|
31
|
+
}
|
|
32
|
+
return options;
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const optionalDateOptions = $derived([
|
|
36
|
+
{ value: "", label: "None" },
|
|
37
|
+
...dateOptions,
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
const textOptions = $derived([
|
|
41
|
+
{ value: "", label: "None" },
|
|
42
|
+
...fields
|
|
43
|
+
.filter((field) => field.type === "string")
|
|
44
|
+
.map((field) => ({ value: field.name, label: field.name })),
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
const error = $derived(draft.enabled ? calendarDraftError(draft, fields) : null);
|
|
48
|
+
|
|
49
|
+
function setEnabled(enabled: boolean): void {
|
|
50
|
+
draft.enabled = enabled;
|
|
51
|
+
if (enabled && !draft.label.trim()) {
|
|
52
|
+
draft.label = defaultLabel.trim() || "Event";
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
</script>
|
|
56
|
+
|
|
57
|
+
<div class="space-y-4 rounded-xl bg-[var(--stor-surface-muted)] p-4">
|
|
58
|
+
<div class="flex items-start justify-between gap-3">
|
|
59
|
+
<div>
|
|
60
|
+
<h3 class="text-base font-semibold text-[var(--stor-ink)]">Calendar</h3>
|
|
61
|
+
<p class="mt-0.5 text-xs text-[var(--stor-muted-text)]">
|
|
62
|
+
Show records from this entity on the Calendar page.
|
|
63
|
+
</p>
|
|
64
|
+
</div>
|
|
65
|
+
<label class="flex items-center gap-2 whitespace-nowrap text-sm text-[var(--stor-ink)]">
|
|
66
|
+
<Checkbox
|
|
67
|
+
checked={draft.enabled}
|
|
68
|
+
onCheckedChange={(checked) => setEnabled(Boolean(checked))}
|
|
69
|
+
aria-label="Show on calendar"
|
|
70
|
+
/>
|
|
71
|
+
On calendar
|
|
72
|
+
</label>
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
{#if draft.enabled}
|
|
76
|
+
{#if dateOptions.length === 0}
|
|
77
|
+
<p class="text-xs text-[var(--stor-muted-text)]">
|
|
78
|
+
Add a datetime field first, then choose it as the calendar date.
|
|
79
|
+
</p>
|
|
80
|
+
{:else}
|
|
81
|
+
<div class="grid gap-3 sm:grid-cols-2">
|
|
82
|
+
<Field
|
|
83
|
+
label="Date field"
|
|
84
|
+
htmlFor="calendar-draft-date"
|
|
85
|
+
required
|
|
86
|
+
>
|
|
87
|
+
<Select
|
|
88
|
+
id="calendar-draft-date"
|
|
89
|
+
value={draft.dateField}
|
|
90
|
+
options={dateOptions}
|
|
91
|
+
placeholder="Choose a date field"
|
|
92
|
+
onValueChange={(value) => (draft.dateField = value)}
|
|
93
|
+
/>
|
|
94
|
+
</Field>
|
|
95
|
+
<Field
|
|
96
|
+
label="Label"
|
|
97
|
+
htmlFor="calendar-draft-label"
|
|
98
|
+
hint="Shown in the agenda and calendar feeds"
|
|
99
|
+
required
|
|
100
|
+
>
|
|
101
|
+
<Input
|
|
102
|
+
id="calendar-draft-label"
|
|
103
|
+
bind:value={draft.label}
|
|
104
|
+
placeholder="e.g. Task due"
|
|
105
|
+
/>
|
|
106
|
+
</Field>
|
|
107
|
+
<Field
|
|
108
|
+
label="End date"
|
|
109
|
+
htmlFor="calendar-draft-end"
|
|
110
|
+
hint="For multi-day records"
|
|
111
|
+
>
|
|
112
|
+
<Select
|
|
113
|
+
id="calendar-draft-end"
|
|
114
|
+
value={draft.endDateField}
|
|
115
|
+
options={optionalDateOptions}
|
|
116
|
+
onValueChange={(value) => (draft.endDateField = value)}
|
|
117
|
+
/>
|
|
118
|
+
</Field>
|
|
119
|
+
<Field
|
|
120
|
+
label="Time field"
|
|
121
|
+
htmlFor="calendar-draft-time"
|
|
122
|
+
hint="Optional HH:mm column"
|
|
123
|
+
>
|
|
124
|
+
<Select
|
|
125
|
+
id="calendar-draft-time"
|
|
126
|
+
value={draft.timeField}
|
|
127
|
+
options={textOptions}
|
|
128
|
+
onValueChange={(value) => (draft.timeField = value)}
|
|
129
|
+
/>
|
|
130
|
+
</Field>
|
|
131
|
+
<Field
|
|
132
|
+
label="Title field"
|
|
133
|
+
htmlFor="calendar-draft-title"
|
|
134
|
+
hint="Defaults to the record name"
|
|
135
|
+
>
|
|
136
|
+
<Select
|
|
137
|
+
id="calendar-draft-title"
|
|
138
|
+
value={draft.titleField}
|
|
139
|
+
options={textOptions}
|
|
140
|
+
onValueChange={(value) => (draft.titleField = value)}
|
|
141
|
+
/>
|
|
142
|
+
</Field>
|
|
143
|
+
</div>
|
|
144
|
+
{#if error}
|
|
145
|
+
<p class="text-xs text-[var(--stor-negative)]" role="alert">{error}</p>
|
|
146
|
+
{/if}
|
|
147
|
+
{/if}
|
|
148
|
+
{/if}
|
|
149
|
+
</div>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { HistoryRecord } from '@hearthlog/runtime/history/contracts.js';
|
|
3
|
+
import { displayHistoryValue } from '@hearthlog/runtime/history/change-display.js';
|
|
4
|
+
|
|
5
|
+
let { change }: { change: HistoryRecord } = $props();
|
|
6
|
+
const verb = $derived(change.kind === 'saved' ? 'Saved values' : change.kind === 'added' ? 'Added' : change.kind === 'removed' ? 'Removed' : 'Changed');
|
|
7
|
+
</script>
|
|
8
|
+
|
|
9
|
+
<section class="py-2" aria-label={change.label}>
|
|
10
|
+
<div class="mb-3 flex flex-wrap items-baseline justify-between gap-2">
|
|
11
|
+
<h4 class="break-words text-sm font-semibold text-[var(--stor-ink)]">{change.label}</h4>
|
|
12
|
+
<span class="text-xs text-[var(--stor-muted-text)]">{verb}</span>
|
|
13
|
+
</div>
|
|
14
|
+
<dl class="space-y-3">
|
|
15
|
+
{#each change.fields as field (field.key)}
|
|
16
|
+
<div class="grid gap-1">
|
|
17
|
+
<dt class="text-xs font-medium capitalize text-[var(--stor-muted-text)]">{field.label}</dt>
|
|
18
|
+
<dd class="grid min-w-0 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-start gap-3 text-sm text-[var(--stor-ink)]">
|
|
19
|
+
{#if change.kind === 'saved'}
|
|
20
|
+
<span class="col-span-3 whitespace-pre-wrap break-words">{displayHistoryValue(field.after)}</span>
|
|
21
|
+
{:else}
|
|
22
|
+
<span class="whitespace-pre-wrap break-words"><span class="sr-only">Before: </span>{displayHistoryValue(field.before)}</span>
|
|
23
|
+
<span aria-hidden="true" class="text-[var(--stor-muted-text)]">→</span>
|
|
24
|
+
<span class="whitespace-pre-wrap break-words font-semibold"><span class="sr-only">After: </span>{displayHistoryValue(field.after)}</span>
|
|
25
|
+
{/if}
|
|
26
|
+
</dd>
|
|
27
|
+
</div>
|
|
28
|
+
{/each}
|
|
29
|
+
</dl>
|
|
30
|
+
</section>
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { tick, untrack } from 'svelte';
|
|
3
|
+
import Button from '@hearthlog/ui/components/ui/Button.svelte';
|
|
4
|
+
import Input from '@hearthlog/ui/components/ui/Input.svelte';
|
|
5
|
+
import { queryDatabase } from '@hearthlog/runtime/db/read-repositories.js';
|
|
6
|
+
import type { ForkReview, HistoryEntry } from '@hearthlog/runtime/history/contracts.js';
|
|
7
|
+
import { events } from '@hearthlog/runtime/store/events.svelte.js';
|
|
8
|
+
import { syncStatus } from '@hearthlog/runtime/store/sync.svelte.js';
|
|
9
|
+
import { notify } from '@hearthlog/ui/state/notifications.svelte.js';
|
|
10
|
+
import { getLedgerClient, type LogSummary } from '@hearthlog/runtime/ledger/index.js';
|
|
11
|
+
import { forkAndActivateLog } from '@hearthlog/runtime/log-management.js';
|
|
12
|
+
import { suggestForkName } from '@hearthlog/runtime/history/lineage.js';
|
|
13
|
+
import { formatNumber } from '@hearthlog/ui/utils/number-format.js';
|
|
14
|
+
|
|
15
|
+
let {
|
|
16
|
+
step,
|
|
17
|
+
actionId,
|
|
18
|
+
currentLogId,
|
|
19
|
+
currentLogName,
|
|
20
|
+
logs,
|
|
21
|
+
onCancel,
|
|
22
|
+
onSwitch,
|
|
23
|
+
onDone,
|
|
24
|
+
onBusyChange,
|
|
25
|
+
}: {
|
|
26
|
+
/** `fork` starts after the opened action; `copy` after the latest one. */
|
|
27
|
+
step: 'fork' | 'copy';
|
|
28
|
+
actionId: string | null;
|
|
29
|
+
currentLogId: string | null;
|
|
30
|
+
currentLogName: string | null;
|
|
31
|
+
logs: LogSummary[];
|
|
32
|
+
onCancel: () => void;
|
|
33
|
+
onSwitch: (logId: string) => Promise<void>;
|
|
34
|
+
onDone: () => void;
|
|
35
|
+
onBusyChange: (busy: boolean) => void;
|
|
36
|
+
} = $props();
|
|
37
|
+
|
|
38
|
+
let entry = $state<HistoryEntry | null>(null);
|
|
39
|
+
let loading = $state(true);
|
|
40
|
+
let name = $state('');
|
|
41
|
+
let namedFor = '';
|
|
42
|
+
let review = $state<ForkReview | null>(null);
|
|
43
|
+
let sourceHead = $state<number | null>(null);
|
|
44
|
+
let reviewLoading = $state(false);
|
|
45
|
+
let reviewRetry = $state(0);
|
|
46
|
+
let busy = $state(false);
|
|
47
|
+
let progress = $state('');
|
|
48
|
+
let error = $state('');
|
|
49
|
+
let request: { key: string; id: string } | null = null;
|
|
50
|
+
|
|
51
|
+
const unavailable = $derived(!syncStatus.isOnline ? 'Connect to create a log. You can still read history offline.'
|
|
52
|
+
: syncStatus.pendingUploads > 0 ? 'Sync your changes before creating a log.' : '');
|
|
53
|
+
|
|
54
|
+
// The action the new log ends with.
|
|
55
|
+
$effect(() => {
|
|
56
|
+
const selected = step === 'copy' ? null : actionId;
|
|
57
|
+
const scope = currentLogId;
|
|
58
|
+
void events.updatedAt;
|
|
59
|
+
if (!scope || untrack(() => busy)) return;
|
|
60
|
+
let cancelled = false;
|
|
61
|
+
loading = true;
|
|
62
|
+
void queryDatabase(r => selected ? r.history.get({ id: selected }) : r.history.latest())
|
|
63
|
+
.then(found => { if (!cancelled) entry = found; })
|
|
64
|
+
.catch(cause => { if (!cancelled) error = message(cause); })
|
|
65
|
+
.finally(() => { if (!cancelled) loading = false; });
|
|
66
|
+
return () => { cancelled = true; };
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
$effect(() => {
|
|
70
|
+
const target = entry;
|
|
71
|
+
if (!target || namedFor === `${step}:${target.id}`) return;
|
|
72
|
+
namedFor = `${step}:${target.id}`;
|
|
73
|
+
name = suggestForkName(untrack(() => logs), currentLogId, target.timestamp);
|
|
74
|
+
// Selected, so typing replaces the suggestion.
|
|
75
|
+
void tick().then(() => (document.getElementById('fork-log-name') as HTMLInputElement | null)?.select());
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
$effect(() => {
|
|
79
|
+
const target = entry?.status === 'ready' ? entry.version : null;
|
|
80
|
+
const scope = currentLogId;
|
|
81
|
+
const online = syncStatus.isOnline;
|
|
82
|
+
void reviewRetry;
|
|
83
|
+
void events.updatedAt;
|
|
84
|
+
if (!scope || target === null || !online || untrack(() => busy)) return;
|
|
85
|
+
let cancelled = false;
|
|
86
|
+
reviewLoading = true;
|
|
87
|
+
review = null;
|
|
88
|
+
sourceHead = null;
|
|
89
|
+
error = '';
|
|
90
|
+
void Promise.all([
|
|
91
|
+
queryDatabase(r => r.history.review(target)),
|
|
92
|
+
getLedgerClient().getLog(scope),
|
|
93
|
+
]).then(([impact, log]) => {
|
|
94
|
+
if (cancelled) return;
|
|
95
|
+
review = impact;
|
|
96
|
+
if (impact.localHead !== log.headVersion) error = 'This log has newer changes to download. Sync, then check again.';
|
|
97
|
+
else sourceHead = log.headVersion;
|
|
98
|
+
}).catch(cause => { if (!cancelled) error = message(cause); })
|
|
99
|
+
.finally(() => { if (!cancelled) reviewLoading = false; });
|
|
100
|
+
return () => { cancelled = true; };
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
function message(cause: unknown): string {
|
|
104
|
+
return cause instanceof Error ? cause.message : 'Could not check this starting point. Try again.';
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function plural(count: number, one: string, many: string): string {
|
|
108
|
+
return `${formatNumber(count)} ${count === 1 ? one : many}`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function create(): Promise<void> {
|
|
112
|
+
const target = entry;
|
|
113
|
+
if (!target || target.version === null || sourceHead === null || !currentLogId || unavailable || busy) return;
|
|
114
|
+
const source = currentLogId;
|
|
115
|
+
const key = JSON.stringify([source, target.version, sourceHead, name.trim()]);
|
|
116
|
+
if (request?.key !== key) request = { key, id: crypto.randomUUID() };
|
|
117
|
+
busy = true;
|
|
118
|
+
onBusyChange(true);
|
|
119
|
+
error = '';
|
|
120
|
+
try {
|
|
121
|
+
const fork = await forkAndActivateLog({ name, targetVersion: target.version, expectedSourceHeadVersion: sourceHead,
|
|
122
|
+
requestId: request.id, onProgress: value => { progress = value; } });
|
|
123
|
+
onDone();
|
|
124
|
+
notify.success(`Opened ${fork.name}`, { label: 'Open original log', run: async () => {
|
|
125
|
+
try { await onSwitch(source); } catch (cause) { notify.error(message(cause)); }
|
|
126
|
+
} });
|
|
127
|
+
} catch (cause) {
|
|
128
|
+
error = message(cause);
|
|
129
|
+
} finally {
|
|
130
|
+
busy = false;
|
|
131
|
+
progress = '';
|
|
132
|
+
onBusyChange(false);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
</script>
|
|
136
|
+
|
|
137
|
+
<form class="w-full space-y-2.5" onsubmit={(event) => { event.preventDefault(); void create(); }}>
|
|
138
|
+
{#if loading && !entry}
|
|
139
|
+
<p role="status" class="text-sm text-[var(--stor-muted-text)] motion-safe:animate-pulse">Finding the starting point…</p>
|
|
140
|
+
{:else if !entry}
|
|
141
|
+
<p class="text-sm text-[var(--stor-muted-text)]">{step === 'copy' ? 'Nothing to copy yet. Save something in this log first.' : 'This action is not in this log any more.'}</p>
|
|
142
|
+
<div class="flex justify-end"><Button variant="ghost" onclick={onCancel}>Cancel</Button></div>
|
|
143
|
+
{:else}
|
|
144
|
+
<fieldset disabled={busy} class="space-y-2.5">
|
|
145
|
+
<div>
|
|
146
|
+
<label for="fork-log-name" class="mb-1.5 block text-sm font-semibold text-[var(--stor-ink)]">New log name</label>
|
|
147
|
+
<Input id="fork-log-name" bind:value={name} placeholder="Name this log" autocomplete="off" />
|
|
148
|
+
</div>
|
|
149
|
+
<p class="text-xs text-[var(--stor-muted-text)]" role="status">
|
|
150
|
+
{#if entry.status !== 'ready'}
|
|
151
|
+
{entry.reason ?? 'This action is not synced yet.'}
|
|
152
|
+
{:else if reviewLoading || !review}
|
|
153
|
+
Checking this starting point…
|
|
154
|
+
{:else}
|
|
155
|
+
<b class="font-semibold text-[var(--stor-ink)]">Keeps {plural(review.keptActions, 'action', 'actions')}.</b>
|
|
156
|
+
{review.laterActions === 0 ? '' : `Leaves out ${plural(review.laterActions, 'newer one', 'newer ones')}.`}
|
|
157
|
+
{currentLogName ?? 'This log'} stays as it is.
|
|
158
|
+
{/if}
|
|
159
|
+
</p>
|
|
160
|
+
</fieldset>
|
|
161
|
+
{#if unavailable}<p class="text-xs text-[var(--stor-muted-text)]">{unavailable}</p>{/if}
|
|
162
|
+
{#if progress}<p role="status" class="text-sm text-[var(--stor-muted-text)]">{progress}</p>{/if}
|
|
163
|
+
{#if error}
|
|
164
|
+
<p role="alert" class="text-sm text-[var(--stor-negative)]">{error}</p>
|
|
165
|
+
{/if}
|
|
166
|
+
<div class="flex flex-wrap justify-end gap-2">
|
|
167
|
+
{#if error && !busy}<Button variant="secondary" onclick={() => reviewRetry++}>Check again</Button>{/if}
|
|
168
|
+
<Button variant="ghost" disabled={busy} onclick={onCancel}>Cancel</Button>
|
|
169
|
+
<Button type="submit" disabled={busy || reviewLoading || sourceHead === null || Boolean(unavailable) || !name.trim() || entry.status !== 'ready'}>
|
|
170
|
+
{busy ? 'Creating and switching…' : 'Create and switch'}
|
|
171
|
+
</Button>
|
|
172
|
+
</div>
|
|
173
|
+
{/if}
|
|
174
|
+
</form>
|