@hearthlog/shell 0.0.0-stage → 0.1.1
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,128 @@
|
|
|
1
|
+
const SELECTED = 'a[data-nav-link][aria-current="page"]';
|
|
2
|
+
|
|
3
|
+
/** Smoothing constant: the pill covers 95% of the distance in about 280 ms, the same as `--stor-tab-motion`. */
|
|
4
|
+
const TIME_CONSTANT_MS = 93;
|
|
5
|
+
|
|
6
|
+
/** Keep following the row after a change, so the pill tracks sub-lists that are still opening or closing. */
|
|
7
|
+
const FOLLOW_MS = 320;
|
|
8
|
+
|
|
9
|
+
interface Box {
|
|
10
|
+
x: number;
|
|
11
|
+
y: number;
|
|
12
|
+
w: number;
|
|
13
|
+
h: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Position of `el` inside the positioned `nav`, read from layout offsets so scrolling and transforms do not matter. */
|
|
17
|
+
function boxIn(el: HTMLElement, nav: HTMLElement): Box {
|
|
18
|
+
let x = 0;
|
|
19
|
+
let y = 0;
|
|
20
|
+
let node: HTMLElement | null = el;
|
|
21
|
+
while (node && node !== nav) {
|
|
22
|
+
x += node.offsetLeft;
|
|
23
|
+
y += node.offsetTop;
|
|
24
|
+
node = node.offsetParent as HTMLElement | null;
|
|
25
|
+
}
|
|
26
|
+
return { x, y, w: el.offsetWidth, h: el.offsetHeight };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Sliding selection pill for the sidebar navigation, the vertical sibling of
|
|
31
|
+
* `tabIndicator`.
|
|
32
|
+
*
|
|
33
|
+
* Measures the selected top-level link (`a[data-nav-link]` with
|
|
34
|
+
* `aria-current`) and glides a raised pill to it. The pill keeps following the
|
|
35
|
+
* row while a sub-list above it opens or closes, so it lands on the row's
|
|
36
|
+
* resting position rather than where the row was when the page changed. The
|
|
37
|
+
* selected link keeps its own background until the pill is placed, and reduced
|
|
38
|
+
* motion snaps.
|
|
39
|
+
*
|
|
40
|
+
* Usage: `{@attach navIndicator}` on the positioned `<nav>` that holds the links.
|
|
41
|
+
*/
|
|
42
|
+
export function navIndicator(nav: HTMLElement): () => void {
|
|
43
|
+
const pill = document.createElement('span');
|
|
44
|
+
pill.className = 'stor-nav-indicator';
|
|
45
|
+
pill.setAttribute('aria-hidden', 'true');
|
|
46
|
+
pill.setAttribute('role', 'presentation');
|
|
47
|
+
nav.insertBefore(pill, nav.firstChild);
|
|
48
|
+
|
|
49
|
+
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
|
|
50
|
+
let shown: Box | null = null;
|
|
51
|
+
let frame = 0;
|
|
52
|
+
let previous = 0;
|
|
53
|
+
let followUntil = 0;
|
|
54
|
+
let destroyed = false;
|
|
55
|
+
|
|
56
|
+
const paint = (box: Box): void => {
|
|
57
|
+
pill.style.width = `${box.w}px`;
|
|
58
|
+
pill.style.height = `${box.h}px`;
|
|
59
|
+
pill.style.transform = `translate(${box.x}px, ${box.y}px)`;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const step = (now: number): void => {
|
|
63
|
+
frame = 0;
|
|
64
|
+
if (destroyed) return;
|
|
65
|
+
const selected = nav.querySelector<HTMLElement>(SELECTED);
|
|
66
|
+
if (!selected || selected.offsetWidth === 0) {
|
|
67
|
+
shown = null;
|
|
68
|
+
nav.classList.remove('stor-nav-sliding');
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const target = boxIn(selected, nav);
|
|
72
|
+
if (!shown || reducedMotion.matches) {
|
|
73
|
+
shown = target;
|
|
74
|
+
} else {
|
|
75
|
+
const k = 1 - Math.exp(-Math.min(48, now - previous) / TIME_CONSTANT_MS);
|
|
76
|
+
shown = {
|
|
77
|
+
x: shown.x + (target.x - shown.x) * k,
|
|
78
|
+
y: shown.y + (target.y - shown.y) * k,
|
|
79
|
+
w: shown.w + (target.w - shown.w) * k,
|
|
80
|
+
h: shown.h + (target.h - shown.h) * k,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
previous = now;
|
|
84
|
+
paint(shown);
|
|
85
|
+
nav.classList.add('stor-nav-sliding');
|
|
86
|
+
const distance =
|
|
87
|
+
Math.abs(target.x - shown.x) + Math.abs(target.y - shown.y) +
|
|
88
|
+
Math.abs(target.w - shown.w) + Math.abs(target.h - shown.h);
|
|
89
|
+
if (distance > 0.5 || now < followUntil) {
|
|
90
|
+
frame = requestAnimationFrame(step);
|
|
91
|
+
} else {
|
|
92
|
+
shown = target;
|
|
93
|
+
paint(target);
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const follow = (): void => {
|
|
98
|
+
if (destroyed) return;
|
|
99
|
+
followUntil = performance.now() + FOLLOW_MS;
|
|
100
|
+
if (!frame) {
|
|
101
|
+
previous = performance.now();
|
|
102
|
+
frame = requestAnimationFrame(step);
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const mutations = new MutationObserver(follow);
|
|
107
|
+
mutations.observe(nav, {
|
|
108
|
+
attributes: true,
|
|
109
|
+
childList: true,
|
|
110
|
+
subtree: true,
|
|
111
|
+
attributeFilter: ['aria-current', 'data-open'],
|
|
112
|
+
});
|
|
113
|
+
const resizes = new ResizeObserver(follow);
|
|
114
|
+
resizes.observe(nav);
|
|
115
|
+
window.addEventListener('resize', follow);
|
|
116
|
+
void document.fonts.ready.then(follow);
|
|
117
|
+
follow();
|
|
118
|
+
|
|
119
|
+
return () => {
|
|
120
|
+
destroyed = true;
|
|
121
|
+
cancelAnimationFrame(frame);
|
|
122
|
+
mutations.disconnect();
|
|
123
|
+
resizes.disconnect();
|
|
124
|
+
window.removeEventListener('resize', follow);
|
|
125
|
+
nav.classList.remove('stor-nav-sliding');
|
|
126
|
+
pill.remove();
|
|
127
|
+
};
|
|
128
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Pure size rules for the resizable desktop sidebar. app.template.html repeats the clamp in its boot script.
|
|
2
|
+
export const SIDEBAR_RAIL_WIDTH = 80;
|
|
3
|
+
export const SIDEBAR_MIN_WIDTH = 192;
|
|
4
|
+
export const SIDEBAR_DEFAULT_WIDTH = 224;
|
|
5
|
+
export const SIDEBAR_MAX_WIDTH = 336;
|
|
6
|
+
/** Halfway between the rail and the minimum: dragging narrower than this folds the sidebar. */
|
|
7
|
+
export const SIDEBAR_FOLD_WIDTH = 136;
|
|
8
|
+
/** A drag within this many pixels of the default width lands on it exactly. */
|
|
9
|
+
export const SIDEBAR_SNAP_RANGE = 8;
|
|
10
|
+
export const SIDEBAR_KEY_STEP = 16;
|
|
11
|
+
export const SIDEBAR_WIDTH_KEY = 'stor:sidebar-width';
|
|
12
|
+
|
|
13
|
+
export function clampSidebarWidth(width: number): number {
|
|
14
|
+
return Math.min(SIDEBAR_MAX_WIDTH, Math.max(SIDEBAR_MIN_WIDTH, Math.round(width)));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Where a drag ends up for a requested width; `width` is null when the drag folds the sidebar. */
|
|
18
|
+
export function resolveSidebarDrag(width: number): { folded: boolean; width: number | null } {
|
|
19
|
+
if (width < SIDEBAR_FOLD_WIDTH) return { folded: true, width: null };
|
|
20
|
+
const snapped = Math.abs(width - SIDEBAR_DEFAULT_WIDTH) <= SIDEBAR_SNAP_RANGE ? SIDEBAR_DEFAULT_WIDTH : width;
|
|
21
|
+
return { folded: false, width: clampSidebarWidth(snapped) };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function readSidebarWidth(storage: Pick<Storage, 'getItem'>): number {
|
|
25
|
+
try {
|
|
26
|
+
const value = Number(storage.getItem(SIDEBAR_WIDTH_KEY));
|
|
27
|
+
return Number.isFinite(value) && value > 0 ? clampSidebarWidth(value) : SIDEBAR_DEFAULT_WIDTH;
|
|
28
|
+
} catch {
|
|
29
|
+
return SIDEBAR_DEFAULT_WIDTH;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { invalidate } from "$app/navigation";
|
|
3
|
+
import { MODULE_REFRESH_KEYS } from '#generated/modules';
|
|
4
|
+
import Drawer from "@hearthlog/ui/components/Drawer.svelte";
|
|
5
|
+
import {
|
|
6
|
+
discardSyncIssue,
|
|
7
|
+
getSyncIssues,
|
|
8
|
+
reapplyLocalSyncIssue,
|
|
9
|
+
rebaseSyncIssue,
|
|
10
|
+
useTheirsSyncIssue,
|
|
11
|
+
type SyncIssue,
|
|
12
|
+
} from "@hearthlog/runtime/store/sync-issues.js";
|
|
13
|
+
import { describeClash, formatClashValue, humanizeField } from "@hearthlog/runtime/store/conflict-merge.js";
|
|
14
|
+
import { refreshEntitySyncState } from "@hearthlog/runtime/store/entity-sync-state.svelte.js";
|
|
15
|
+
import { notify } from "@hearthlog/ui/state/notifications.svelte.js";
|
|
16
|
+
import { runSyncCoordinator, signalLocalChange } from "@hearthlog/runtime/store/sync.svelte.js";
|
|
17
|
+
|
|
18
|
+
let { show, onClose }: { show: boolean; onClose: () => void } = $props();
|
|
19
|
+
let issues = $state<SyncIssue[]>([]);
|
|
20
|
+
let loading = $state(false);
|
|
21
|
+
let actingOn = $state<string | null>(null);
|
|
22
|
+
|
|
23
|
+
async function load(): Promise<void> {
|
|
24
|
+
loading = true;
|
|
25
|
+
try {
|
|
26
|
+
issues = await getSyncIssues();
|
|
27
|
+
} catch (error) {
|
|
28
|
+
notify.error(error instanceof Error ? error.message : "Could not load sync issues");
|
|
29
|
+
} finally {
|
|
30
|
+
loading = false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function refreshApp(): Promise<void> {
|
|
35
|
+
await refreshEntitySyncState();
|
|
36
|
+
await Promise.all([
|
|
37
|
+
...MODULE_REFRESH_KEYS.map((key) => invalidate(key)),
|
|
38
|
+
invalidate("app:entities"),
|
|
39
|
+
]);
|
|
40
|
+
await load();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function act(issue: SyncIssue, action: "rebase" | "reapply" | "discard" | "theirs"): Promise<void> {
|
|
44
|
+
actingOn = issue.operationId;
|
|
45
|
+
try {
|
|
46
|
+
if (action === "rebase") await rebaseSyncIssue(issue.operationId);
|
|
47
|
+
if (action === "reapply") await reapplyLocalSyncIssue(issue.operationId);
|
|
48
|
+
if (action === "discard") await discardSyncIssue(issue.operationId);
|
|
49
|
+
if (action === "theirs") await useTheirsSyncIssue(issue.operationId);
|
|
50
|
+
if (action !== "discard") {
|
|
51
|
+
signalLocalChange();
|
|
52
|
+
await runSyncCoordinator("manual");
|
|
53
|
+
}
|
|
54
|
+
await refreshApp();
|
|
55
|
+
notify.success(action === "discard" ? "Local change discarded"
|
|
56
|
+
: action === "theirs" ? "Kept the other device's values"
|
|
57
|
+
: "Local change queued again");
|
|
58
|
+
} catch (error) {
|
|
59
|
+
notify.error(error instanceof Error ? error.message : "Could not resolve sync issue");
|
|
60
|
+
} finally {
|
|
61
|
+
actingOn = null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function formatTime(value: string): string {
|
|
66
|
+
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(new Date(value));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
$effect(() => {
|
|
70
|
+
if (show) void load();
|
|
71
|
+
});
|
|
72
|
+
</script>
|
|
73
|
+
|
|
74
|
+
<Drawer {show} title="Sync issues" description="Resolve local changes the Ledger could not accept." {onClose}>
|
|
75
|
+
{#if loading && issues.length === 0}
|
|
76
|
+
<p class="py-10 text-center text-sm text-[var(--stor-muted-text)]">Loading sync issues…</p>
|
|
77
|
+
{:else if issues.length === 0}
|
|
78
|
+
<section class="rounded-xl border border-emerald-200 bg-emerald-50 p-5">
|
|
79
|
+
<h3 class="font-semibold text-emerald-900">No blocked changes</h3>
|
|
80
|
+
<p class="mt-1 text-sm text-emerald-700">Local changes can sync normally.</p>
|
|
81
|
+
</section>
|
|
82
|
+
{:else}
|
|
83
|
+
<div class="space-y-4">
|
|
84
|
+
{#each issues as issue (issue.operationId)}
|
|
85
|
+
<article class="rounded-xl border border-red-200 bg-white p-4 ">
|
|
86
|
+
<div class="flex items-start justify-between gap-3">
|
|
87
|
+
<div class="min-w-0">
|
|
88
|
+
<div class="text-sm font-semibold text-stone-950">{issue.conflicts.length > 0 ? "Edited on two devices" : issue.errorCode ?? "Ledger rejected this change"}</div>
|
|
89
|
+
{#if issue.conflicts.length === 0}
|
|
90
|
+
<p class="mt-1 text-sm leading-5 text-stone-600">{issue.errorMessage ?? "The remote log could not accept this local operation."}</p>
|
|
91
|
+
{/if}
|
|
92
|
+
</div>
|
|
93
|
+
<span class="rounded-full bg-red-50 px-2 py-1 text-[10.5px] font-bold uppercase text-red-700">Blocked</span>
|
|
94
|
+
</div>
|
|
95
|
+
{#if issue.conflicts.length > 0}
|
|
96
|
+
<ul class="mt-3 space-y-2" aria-label="Conflicting changes">
|
|
97
|
+
{#each issue.conflicts as clash}
|
|
98
|
+
<li class="rounded-xl bg-[var(--stor-surface-muted)] p-3 text-sm">
|
|
99
|
+
{#if clash.kind === "field"}
|
|
100
|
+
<div class="font-semibold text-[var(--stor-ink)]">{humanizeField(clash.field)}</div>
|
|
101
|
+
<dl class="mt-1 grid grid-cols-2 gap-3 text-xs">
|
|
102
|
+
<div><dt class="text-[var(--stor-muted-text)]">Yours</dt><dd class="break-words text-[var(--stor-ink)]">{formatClashValue(clash.mine)}</dd></div>
|
|
103
|
+
<div><dt class="text-[var(--stor-muted-text)]">Theirs</dt><dd class="break-words text-[var(--stor-ink)]">{formatClashValue(clash.theirs)}</dd></div>
|
|
104
|
+
</dl>
|
|
105
|
+
{:else}
|
|
106
|
+
<p class="text-[var(--stor-ink)]">{describeClash(clash)}</p>
|
|
107
|
+
{/if}
|
|
108
|
+
</li>
|
|
109
|
+
{/each}
|
|
110
|
+
</ul>
|
|
111
|
+
{/if}
|
|
112
|
+
<dl class="mt-3 grid grid-cols-2 gap-2 text-xs text-[var(--stor-muted-text)]">
|
|
113
|
+
<div><dt class="font-semibold text-stone-700">Attempts</dt><dd>{issue.retryCount} retries · {issue.rebaseCount} rebases</dd></div>
|
|
114
|
+
<div><dt class="font-semibold text-stone-700">Last attempt</dt><dd>{formatTime(issue.updatedAt)}</dd></div>
|
|
115
|
+
</dl>
|
|
116
|
+
{#if issue.affectedEntities.length > 0}
|
|
117
|
+
<div class="mt-3 flex flex-wrap gap-1.5">
|
|
118
|
+
{#each issue.affectedEntities as entity}
|
|
119
|
+
<span class="rounded-md bg-[var(--stor-surface-muted)] px-2 py-1 font-mono text-[10.5px] text-stone-600">{entity}</span>
|
|
120
|
+
{/each}
|
|
121
|
+
</div>
|
|
122
|
+
{/if}
|
|
123
|
+
<details class="mt-3 rounded-xl bg-[var(--stor-surface-muted)] p-3 text-xs text-stone-600">
|
|
124
|
+
<summary class="cursor-pointer font-semibold text-stone-700">Inspect events and history</summary>
|
|
125
|
+
<div class="mt-3 space-y-2">
|
|
126
|
+
{#each issue.events as event}
|
|
127
|
+
<div class="rounded-lg bg-[var(--stor-surface-muted)] p-2"><span class="font-mono font-semibold">{event.type}</span><div class="mt-1 break-all text-[var(--stor-muted-text)]">{event.id}</div></div>
|
|
128
|
+
{/each}
|
|
129
|
+
{#each issue.history as entry}
|
|
130
|
+
<div class="border-l-2 border-[var(--stor-border)] pl-2"><span class="font-semibold">{entry.action}</span> · {formatTime(entry.createdAt)}</div>
|
|
131
|
+
{/each}
|
|
132
|
+
</div>
|
|
133
|
+
</details>
|
|
134
|
+
<div class="mt-4 grid gap-2 sm:grid-cols-2">
|
|
135
|
+
{#if issue.canRebase}
|
|
136
|
+
<button type="button" class="rounded-xl bg-stone-950 px-3 py-2.5 text-sm font-semibold text-white disabled:opacity-50" disabled={actingOn !== null} onclick={() => void act(issue, "rebase")}>{issue.conflicts.length > 0 ? "Keep mine" : "Retry with latest"}</button>
|
|
137
|
+
{/if}
|
|
138
|
+
{#if issue.conflicts.length > 0}
|
|
139
|
+
<button type="button" class="rounded-xl border border-[var(--stor-field-border)] bg-white px-3 py-2.5 text-sm font-semibold text-stone-800 disabled:opacity-50" disabled={actingOn !== null} onclick={() => void act(issue, "theirs")}>Use theirs</button>
|
|
140
|
+
{:else if issue.canReapply}
|
|
141
|
+
<button type="button" class="rounded-xl border border-[var(--stor-field-border)] bg-white px-3 py-2.5 text-sm font-semibold text-stone-800 disabled:opacity-50" disabled={actingOn !== null} onclick={() => void act(issue, "reapply")}>Reapply as new</button>
|
|
142
|
+
{/if}
|
|
143
|
+
<button type="button" class="rounded-xl border border-red-200 bg-red-50 px-3 py-2.5 text-sm font-semibold text-red-700 disabled:opacity-50" disabled={actingOn !== null} onclick={() => void act(issue, "discard")}>Discard local</button>
|
|
144
|
+
</div>
|
|
145
|
+
</article>
|
|
146
|
+
{/each}
|
|
147
|
+
</div>
|
|
148
|
+
{/if}
|
|
149
|
+
</Drawer>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { getEntitySyncState } from "@hearthlog/runtime/store/entity-sync-state.svelte.js";
|
|
3
|
+
|
|
4
|
+
let { entityId }: { entityId: string } = $props();
|
|
5
|
+
const state = $derived(getEntitySyncState(entityId));
|
|
6
|
+
</script>
|
|
7
|
+
|
|
8
|
+
{#if state}
|
|
9
|
+
<span
|
|
10
|
+
class="shrink-0 rounded-full border px-1.5 py-0.5 text-[10.5px] font-semibold {state === 'blocked'
|
|
11
|
+
? 'border-red-200 bg-red-50 text-red-700'
|
|
12
|
+
: 'border-amber-200 bg-amber-50 text-amber-700'}"
|
|
13
|
+
title={state === "blocked" ? "This local change needs attention" : "Waiting to sync"}
|
|
14
|
+
>
|
|
15
|
+
{state === "blocked" ? "Blocked" : "Pending"}
|
|
16
|
+
</span>
|
|
17
|
+
{/if}
|