@lapis-notes/bases 0.2.1 → 0.3.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/CHANGELOG.md +20 -0
- package/README.md +1 -1
- package/dist/bases-plugin.js +18 -21
- package/dist/bases-view/bases.svelte.d.ts +7 -0
- package/dist/bases-view/bases.svelte.js +37 -7
- package/dist/bases-view/card-view/components/card-placeholder.svelte +7 -3
- package/dist/bases-view/card-view/components/card.svelte +23 -15
- package/dist/bases-view/cell.svelte +147 -16
- package/dist/bases-view/cell.svelte.d.ts +2 -0
- package/dist/bases-view/columns.js +3 -2
- package/dist/bases-view/components/bases-embed.svelte +28 -0
- package/dist/bases-view/components/bases-embed.svelte.d.ts +12 -0
- package/dist/bases-view/components/bases-error.svelte +26 -0
- package/dist/bases-view/components/bases-error.svelte.d.ts +6 -0
- package/dist/bases-view/components/datetime.svelte +3 -0
- package/dist/bases-view/components/datetime.svelte.d.ts +1 -0
- package/dist/bases-view/components/file-preview.svelte +57 -0
- package/dist/bases-view/components/file-preview.svelte.d.ts +8 -0
- package/dist/bases-view/components/file.svelte +22 -5
- package/dist/bases-view/components/property-picker.svelte +147 -0
- package/dist/bases-view/components/property-picker.svelte.d.ts +13 -0
- package/dist/bases-view/components/sort/sort-root.svelte +107 -88
- package/dist/bases-view/components/sort/sort-root.svelte.d.ts +1 -0
- package/dist/bases-view/components/views.svelte +96 -42
- package/dist/bases-view/document-core.js +30 -0
- package/dist/bases-view/filter-parser.d.ts +1 -1
- package/dist/bases-view/filter-parser.js +2 -1
- package/dist/bases-view/index.js +15 -0
- package/dist/bases-view/kanban-view/configuration.d.ts +8 -0
- package/dist/bases-view/kanban-view/configuration.js +40 -0
- package/dist/bases-view/kanban-view/index.d.ts +55 -0
- package/dist/bases-view/kanban-view/index.js +296 -0
- package/dist/bases-view/kanban-view/kanban.svelte +284 -0
- package/dist/bases-view/kanban-view/kanban.svelte.d.ts +7 -0
- package/dist/bases-view/kanban-view/mapping.d.ts +77 -0
- package/dist/bases-view/kanban-view/mapping.js +206 -0
- package/dist/bases-view/kanban-view/markdown-detail.svelte +123 -0
- package/dist/bases-view/kanban-view/markdown-detail.svelte.d.ts +13 -0
- package/dist/bases-view/kanban-view/settings.svelte +87 -0
- package/dist/bases-view/kanban-view/settings.svelte.d.ts +7 -0
- package/dist/bases-view/list-view/list.svelte +84 -84
- package/dist/bases-view/map-view/view.svelte +6 -1
- package/dist/bases-view/metadata-invalidation-core.d.ts +1 -0
- package/dist/bases-view/metadata-invalidation-core.js +3 -2
- package/dist/bases-view/models.d.ts +26 -0
- package/dist/bases-view/unknown-view/view.svelte +13 -6
- package/dist/bases-view/view-header.svelte +20 -6
- package/dist/bases-view/view-registration-inventory.d.ts +8 -0
- package/dist/bases-view/view-registration-inventory.js +2 -0
- package/dist/bases-view/view-settings.svelte +213 -237
- package/dist/index.d.ts +1 -1
- package/dist/styles.css +495 -74
- package/manifest.json +2 -1
- package/package.json +3 -2
- package/registry.json +85 -1
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import {
|
|
3
|
+
KanbanBoard,
|
|
4
|
+
KanbanCardDetail,
|
|
5
|
+
KanbanActivityView,
|
|
6
|
+
type KanbanColumnData,
|
|
7
|
+
type KanbanDetailBodyContext,
|
|
8
|
+
} from "@lapismd/design-core/workspace/kanban";
|
|
9
|
+
import { Button } from "@lapismd/design-core/shadcn/button";
|
|
10
|
+
import * as Popover from "@lapismd/design-core/shadcn/popover";
|
|
11
|
+
import FilterIcon from "@lucide/svelte/icons/list-filter";
|
|
12
|
+
import type { BasesEntry } from "../bases.svelte";
|
|
13
|
+
import type { KanbanBasesView } from "./index";
|
|
14
|
+
import FilterGroup from "../components/filters/filter-group.svelte";
|
|
15
|
+
import MarkdownDetail from "./markdown-detail.svelte";
|
|
16
|
+
import { buildKanbanColumns, toCardData, valueToText } from "./mapping";
|
|
17
|
+
import { markdownExcerpt } from "./configuration";
|
|
18
|
+
|
|
19
|
+
let { view }: { view: KanbanBasesView } = $props();
|
|
20
|
+
let summaryById = $state<Record<string, string>>({});
|
|
21
|
+
const cachedBodies = new Map<string, { mtime: number; summary: string }>();
|
|
22
|
+
let selectedEntry = $state<BasesEntry | null>(null);
|
|
23
|
+
let selectedColumnId = $state("");
|
|
24
|
+
let detailOpen = $state(false);
|
|
25
|
+
let markdownDetail = $state<{ flush(): Promise<boolean> } | null>(null);
|
|
26
|
+
let openingError = $state("");
|
|
27
|
+
|
|
28
|
+
const fields = $derived({
|
|
29
|
+
titleProperty: view.titleProperty,
|
|
30
|
+
chipProperty: view.chipProperty,
|
|
31
|
+
displayProperties: view.displayProperties,
|
|
32
|
+
descriptionProperty: view.descriptionProperty,
|
|
33
|
+
tagsProperty: view.tagsProperty,
|
|
34
|
+
badgeProperty: view.badgeProperty,
|
|
35
|
+
badgeLabel: view.badgeLabel,
|
|
36
|
+
badgeColor: view.badgeColor,
|
|
37
|
+
propertyLabels: view.propertyLabels,
|
|
38
|
+
propertyTypes: view.propertyTypes,
|
|
39
|
+
summaries: summaryById,
|
|
40
|
+
});
|
|
41
|
+
const filtered = $derived.by(() => {
|
|
42
|
+
try {
|
|
43
|
+
return { ...view.filterGroups(), error: "" };
|
|
44
|
+
} catch (error) {
|
|
45
|
+
return {
|
|
46
|
+
groups: [],
|
|
47
|
+
totals: {},
|
|
48
|
+
error: error instanceof Error ? error.message : String(error),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
const built = $derived(
|
|
53
|
+
buildKanbanColumns({
|
|
54
|
+
...fields,
|
|
55
|
+
groups: filtered.groups,
|
|
56
|
+
groupProperty: view.data.groupBy ?? "",
|
|
57
|
+
columnOrder: view.columnOrder,
|
|
58
|
+
columnMeta: view.columnMeta,
|
|
59
|
+
columnTotals: filtered.totals,
|
|
60
|
+
}),
|
|
61
|
+
);
|
|
62
|
+
const hasGrouping = $derived(Boolean(view.data.groupBy));
|
|
63
|
+
const activityEntries = $derived(
|
|
64
|
+
built.columns.flatMap((column) =>
|
|
65
|
+
column.cards.map((card) => ({
|
|
66
|
+
card,
|
|
67
|
+
columnId: column.id,
|
|
68
|
+
date: view.dateProperty
|
|
69
|
+
? valueToText(view.findEntry(card.id)?.getValue(view.dateProperty))
|
|
70
|
+
: null,
|
|
71
|
+
})),
|
|
72
|
+
),
|
|
73
|
+
);
|
|
74
|
+
const selected = $derived.by(() => {
|
|
75
|
+
if (!selectedEntry) return null;
|
|
76
|
+
const entry = view.findEntry(selectedEntry.file.path) ?? selectedEntry;
|
|
77
|
+
const column =
|
|
78
|
+
built.columns.find((column) =>
|
|
79
|
+
column.cards.some((card) => card.id === entry.id),
|
|
80
|
+
) ?? built.columns.find((column) => column.id === selectedColumnId);
|
|
81
|
+
return {
|
|
82
|
+
entry,
|
|
83
|
+
column,
|
|
84
|
+
card: toCardData(entry, column?.color ?? "#0e7490", fields),
|
|
85
|
+
};
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
$effect(() => {
|
|
89
|
+
const entries = view.data.data;
|
|
90
|
+
const description = view.descriptionProperty;
|
|
91
|
+
const files = entries
|
|
92
|
+
.filter(
|
|
93
|
+
(entry) => !description || !valueToText(entry.getValue(description)),
|
|
94
|
+
)
|
|
95
|
+
.map((entry) => ({
|
|
96
|
+
id: entry.id,
|
|
97
|
+
file: entry.file,
|
|
98
|
+
mtime: entry.file.stat.mtime,
|
|
99
|
+
}));
|
|
100
|
+
let cancelled = false;
|
|
101
|
+
void (async () => {
|
|
102
|
+
let cursor = 0;
|
|
103
|
+
const summaries: Record<string, string> = {};
|
|
104
|
+
await Promise.all(
|
|
105
|
+
Array.from({ length: Math.min(4, files.length) }, async () => {
|
|
106
|
+
while (cursor < files.length) {
|
|
107
|
+
const { id, file, mtime } = files[cursor++];
|
|
108
|
+
const cached = cachedBodies.get(id);
|
|
109
|
+
try {
|
|
110
|
+
const summary =
|
|
111
|
+
cached?.mtime === mtime
|
|
112
|
+
? cached.summary
|
|
113
|
+
: markdownExcerpt(
|
|
114
|
+
await view.controller.app.vault.cachedRead(file),
|
|
115
|
+
);
|
|
116
|
+
if (cancelled) return;
|
|
117
|
+
cachedBodies.set(id, { mtime, summary });
|
|
118
|
+
summaries[id] = summary;
|
|
119
|
+
} catch {
|
|
120
|
+
if (!cancelled) cachedBodies.delete(id);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}),
|
|
124
|
+
);
|
|
125
|
+
if (!cancelled) summaryById = summaries;
|
|
126
|
+
})();
|
|
127
|
+
return () => {
|
|
128
|
+
cancelled = true;
|
|
129
|
+
};
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
async function handleCardOpen(
|
|
133
|
+
cardId: string,
|
|
134
|
+
columnId: string,
|
|
135
|
+
): Promise<void> {
|
|
136
|
+
openingError = "";
|
|
137
|
+
try {
|
|
138
|
+
if (detailOpen && !((await markdownDetail?.flush()) ?? true)) return;
|
|
139
|
+
const entry = view.findEntry(cardId);
|
|
140
|
+
if (!entry) return;
|
|
141
|
+
selectedEntry = entry;
|
|
142
|
+
selectedColumnId = columnId;
|
|
143
|
+
detailOpen = true;
|
|
144
|
+
} catch (error) {
|
|
145
|
+
openingError = error instanceof Error ? error.message : String(error);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function handleMoveCardToColumn(
|
|
150
|
+
cardId: string,
|
|
151
|
+
columnId: string,
|
|
152
|
+
): Promise<void> {
|
|
153
|
+
const property = view.data.groupBy;
|
|
154
|
+
if (!property || !view.canMoveCards) return;
|
|
155
|
+
const entry = view.findEntry(cardId);
|
|
156
|
+
if (entry)
|
|
157
|
+
await view.setGroupValue(
|
|
158
|
+
entry,
|
|
159
|
+
property,
|
|
160
|
+
built.groupValues[columnId] ?? null,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
</script>
|
|
164
|
+
|
|
165
|
+
<div class="kanban-view-host">
|
|
166
|
+
{#if openingError || filtered.error}<p role="alert">
|
|
167
|
+
{openingError || filtered.error}
|
|
168
|
+
</p>{/if}
|
|
169
|
+
{#if view.type === "activity" && !view.dateProperty}<p role="status">
|
|
170
|
+
Choose a date property to arrange activity. Notes without dates are shown
|
|
171
|
+
under Undated.
|
|
172
|
+
</p>{/if}
|
|
173
|
+
<div class="bases-kanban-canvas">
|
|
174
|
+
{#if view.type === "activity"}
|
|
175
|
+
<KanbanActivityView
|
|
176
|
+
entries={activityEntries}
|
|
177
|
+
columns={hasGrouping ? built.columns : []}
|
|
178
|
+
onCardOpen={handleCardOpen}
|
|
179
|
+
/>
|
|
180
|
+
{:else}
|
|
181
|
+
<KanbanBoard
|
|
182
|
+
columns={hasGrouping ? built.columns : []}
|
|
183
|
+
movesEnabled={view.canMoveCards}
|
|
184
|
+
anchorColumnId={view.anchorColumnId}
|
|
185
|
+
onCardOpen={handleCardOpen}
|
|
186
|
+
onMoveCardToColumn={handleMoveCardToColumn}
|
|
187
|
+
onReorderColumns={(ids) => view.setColumnOrder(ids)}
|
|
188
|
+
onEditColumn={(id, meta) => view.setColumnMeta(id, meta)}
|
|
189
|
+
{columnActions}
|
|
190
|
+
ariaLabel="Kanban board"
|
|
191
|
+
emptyTitle={hasGrouping
|
|
192
|
+
? "No notes match this board"
|
|
193
|
+
: "Group this view to see a board"}
|
|
194
|
+
emptyDescription={hasGrouping
|
|
195
|
+
? "Change the active filters to surface notes on this board."
|
|
196
|
+
: "Choose a group-by property in the view settings to arrange notes into kanban columns."}
|
|
197
|
+
/>
|
|
198
|
+
{/if}
|
|
199
|
+
</div>
|
|
200
|
+
</div>
|
|
201
|
+
|
|
202
|
+
{#snippet columnActions(column: KanbanColumnData)}
|
|
203
|
+
<Popover.Root
|
|
204
|
+
onOpenChange={(open) => {
|
|
205
|
+
if (open) view.ensureColumnFilter(column.id);
|
|
206
|
+
}}
|
|
207
|
+
>
|
|
208
|
+
<Popover.Trigger
|
|
209
|
+
>{#snippet child({ props })}<Button
|
|
210
|
+
{...props}
|
|
211
|
+
variant="ghost"
|
|
212
|
+
size="icon"
|
|
213
|
+
aria-label={`Filter ${column.label} column`}><FilterIcon /></Button
|
|
214
|
+
>{/snippet}</Popover.Trigger
|
|
215
|
+
>
|
|
216
|
+
<Popover.Content style="width: min(90vw, 36rem)">
|
|
217
|
+
<p>Filter cards in {column.label}</p>
|
|
218
|
+
<FilterGroup
|
|
219
|
+
controller={view.controller}
|
|
220
|
+
bind:filter={
|
|
221
|
+
() => view.columnFilters[column.id] ?? { and: [] },
|
|
222
|
+
(filter) => view.setColumnFilter(column.id, filter)
|
|
223
|
+
}
|
|
224
|
+
/>
|
|
225
|
+
</Popover.Content>
|
|
226
|
+
</Popover.Root>
|
|
227
|
+
{/snippet}
|
|
228
|
+
|
|
229
|
+
{#if selected}
|
|
230
|
+
<KanbanCardDetail
|
|
231
|
+
card={selected.card}
|
|
232
|
+
columns={built.columns}
|
|
233
|
+
columnId={selected.column?.id ?? selectedColumnId}
|
|
234
|
+
columnLabel={selected.column?.label ?? ""}
|
|
235
|
+
bind:open={detailOpen}
|
|
236
|
+
movesEnabled={view.canMoveCards}
|
|
237
|
+
closeOnMove={false}
|
|
238
|
+
onClose={() => {
|
|
239
|
+
selectedEntry = null;
|
|
240
|
+
}}
|
|
241
|
+
beforeClose={async () => (await markdownDetail?.flush()) ?? true}
|
|
242
|
+
onMoveToColumn={(id) => handleMoveCardToColumn(selected!.entry.id, id)}
|
|
243
|
+
onSaveTitle={view.canEditTitle
|
|
244
|
+
? async (title) => {
|
|
245
|
+
if (!((await markdownDetail?.flush()) ?? true))
|
|
246
|
+
throw new Error("Save the body before editing its title.");
|
|
247
|
+
return await view.saveTitle(selected!.entry, title);
|
|
248
|
+
}
|
|
249
|
+
: undefined}
|
|
250
|
+
body={detailBody}
|
|
251
|
+
brandLabel="Notes"
|
|
252
|
+
/>
|
|
253
|
+
{/if}
|
|
254
|
+
|
|
255
|
+
{#snippet detailBody(context: KanbanDetailBodyContext)}
|
|
256
|
+
{#if selected}
|
|
257
|
+
<MarkdownDetail
|
|
258
|
+
bind:this={markdownDetail}
|
|
259
|
+
app={view.controller.app}
|
|
260
|
+
file={selected.entry.file}
|
|
261
|
+
editing={context.editing}
|
|
262
|
+
readOnly={view.controller.readOnly}
|
|
263
|
+
onEditingChange={context.setEditing}
|
|
264
|
+
/>
|
|
265
|
+
{/if}
|
|
266
|
+
{/snippet}
|
|
267
|
+
|
|
268
|
+
<style>
|
|
269
|
+
.kanban-view-host {
|
|
270
|
+
min-width: 0;
|
|
271
|
+
width: 100%;
|
|
272
|
+
height: 100%;
|
|
273
|
+
min-height: 0;
|
|
274
|
+
display: flex;
|
|
275
|
+
flex-direction: column;
|
|
276
|
+
overflow: hidden;
|
|
277
|
+
}
|
|
278
|
+
.bases-kanban-canvas {
|
|
279
|
+
flex: 1;
|
|
280
|
+
min-height: 0;
|
|
281
|
+
min-width: 0;
|
|
282
|
+
--kanban-board-height: 100%;
|
|
283
|
+
}
|
|
284
|
+
</style>
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure mapping helpers turning Bases query results into Design Core Kanban
|
|
3
|
+
* Board data. Kept DOM-free so the grouping, ordering, colour and card-field
|
|
4
|
+
* rules are unit-testable without mounting the view.
|
|
5
|
+
*/
|
|
6
|
+
import type { KanbanCardData, KanbanColumnData } from "@lapismd/design-core/workspace/kanban";
|
|
7
|
+
import type { BasesEntry, BasesEntryGroup, BasesPropertyId } from "../bases.svelte";
|
|
8
|
+
/** Column id used for the group holding entries without a groupBy value. */
|
|
9
|
+
export declare const UNGROUPED_COLUMN_ID = "__bases_kanban_ungrouped__";
|
|
10
|
+
/** Fallback label for the ungrouped column. */
|
|
11
|
+
export declare const UNGROUPED_COLUMN_LABEL = "Ungrouped";
|
|
12
|
+
/** Per-column presentation persisted in the view document. */
|
|
13
|
+
export interface KanbanColumnMeta {
|
|
14
|
+
title?: string;
|
|
15
|
+
color?: string;
|
|
16
|
+
}
|
|
17
|
+
export type KanbanColumnMetaMap = Record<string, KanbanColumnMeta>;
|
|
18
|
+
/**
|
|
19
|
+
* AA-contrast lane palette (the Design Core kanban swatch hues). Columns
|
|
20
|
+
* without an explicit `columnMeta` colour cycle through this list.
|
|
21
|
+
*/
|
|
22
|
+
export declare const KANBAN_LANE_COLORS: string[];
|
|
23
|
+
/** Renders any Bases value (Value instances, arrays, tag objects) as text. */
|
|
24
|
+
export declare function valueToText(value: unknown): string;
|
|
25
|
+
/** Renders a multi-value Bases field (tags, multitext) as string items. */
|
|
26
|
+
export declare function valueToStrings(value: unknown): string[];
|
|
27
|
+
export interface KanbanColumnSource {
|
|
28
|
+
/** Group key text; empty for the ungrouped bucket. */
|
|
29
|
+
id: string;
|
|
30
|
+
label: string;
|
|
31
|
+
/** Raw group value written back to the groupBy property on move. */
|
|
32
|
+
groupValue: unknown;
|
|
33
|
+
entries: BasesEntry[];
|
|
34
|
+
}
|
|
35
|
+
/** Converts Bases entry groups into ordered column sources. */
|
|
36
|
+
export declare function rawGroupValue(value: unknown): unknown;
|
|
37
|
+
export declare function groupIdentity(property: string, value: unknown): string;
|
|
38
|
+
export declare function toColumnSources(groups: BasesEntryGroup[], groupProperty?: string): KanbanColumnSource[];
|
|
39
|
+
/** Applies the persisted column order, keeping unknown/new columns at the end. */
|
|
40
|
+
export declare function applyColumnOrder(sources: KanbanColumnSource[], columnOrder: string[]): KanbanColumnSource[];
|
|
41
|
+
export interface KanbanCardFields {
|
|
42
|
+
titleProperty: BasesPropertyId;
|
|
43
|
+
/** Visible property columns rendered in the card meta cells. */
|
|
44
|
+
displayProperties: BasesPropertyId[];
|
|
45
|
+
chipProperty?: BasesPropertyId | null;
|
|
46
|
+
descriptionProperty?: BasesPropertyId | null;
|
|
47
|
+
tagsProperty?: BasesPropertyId;
|
|
48
|
+
badgeProperty?: BasesPropertyId | null;
|
|
49
|
+
badgeLabel?: string;
|
|
50
|
+
badgeColor?: string;
|
|
51
|
+
propertyLabels?: Record<string, string>;
|
|
52
|
+
propertyTypes?: Record<string, string>;
|
|
53
|
+
summaries?: Record<string, string>;
|
|
54
|
+
}
|
|
55
|
+
/** Formats a date (epoch ms, Date, ISO string) as card meta text (e.g. "Sep 12"). */
|
|
56
|
+
export declare function formatDateText(value: unknown): string;
|
|
57
|
+
/** Renders a display-property value, shortening ISO dates for the meta cells. */
|
|
58
|
+
export declare function formatDisplayText(value: unknown): string;
|
|
59
|
+
export declare function propertyText(value: unknown, type?: string): string;
|
|
60
|
+
/** Maps one Bases entry onto kanban card data for a given lane colour. */
|
|
61
|
+
export declare function toCardData(entry: BasesEntry, color: string, fields: KanbanCardFields): KanbanCardData;
|
|
62
|
+
export interface BuildKanbanColumnsInput extends KanbanCardFields {
|
|
63
|
+
groupProperty?: string;
|
|
64
|
+
columnTotals?: Record<string, number>;
|
|
65
|
+
groups: BasesEntryGroup[];
|
|
66
|
+
titleProperty: BasesPropertyId;
|
|
67
|
+
displayProperties: BasesPropertyId[];
|
|
68
|
+
columnOrder: string[];
|
|
69
|
+
columnMeta: KanbanColumnMetaMap;
|
|
70
|
+
}
|
|
71
|
+
export interface BuiltKanbanColumns {
|
|
72
|
+
columns: KanbanColumnData[];
|
|
73
|
+
/** Column id → raw group value for frontmatter writes on move. */
|
|
74
|
+
groupValues: Record<string, unknown>;
|
|
75
|
+
}
|
|
76
|
+
/** Builds Design Core kanban columns (and the move-target value map). */
|
|
77
|
+
export declare function buildKanbanColumns(input: BuildKanbanColumnsInput): BuiltKanbanColumns;
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/** Column id used for the group holding entries without a groupBy value. */
|
|
2
|
+
export const UNGROUPED_COLUMN_ID = "__bases_kanban_ungrouped__";
|
|
3
|
+
/** Fallback label for the ungrouped column. */
|
|
4
|
+
export const UNGROUPED_COLUMN_LABEL = "Ungrouped";
|
|
5
|
+
/**
|
|
6
|
+
* AA-contrast lane palette (the Design Core kanban swatch hues). Columns
|
|
7
|
+
* without an explicit `columnMeta` colour cycle through this list.
|
|
8
|
+
*/
|
|
9
|
+
export const KANBAN_LANE_COLORS = [
|
|
10
|
+
"#1d4ed8",
|
|
11
|
+
"#64748b",
|
|
12
|
+
"#a16207",
|
|
13
|
+
"#b45309",
|
|
14
|
+
"#4d7c0f",
|
|
15
|
+
"#0e7490",
|
|
16
|
+
"#6d28d9",
|
|
17
|
+
"#7e22ce",
|
|
18
|
+
"#a21caf",
|
|
19
|
+
];
|
|
20
|
+
/** Renders any Bases value (Value instances, arrays, tag objects) as text. */
|
|
21
|
+
export function valueToText(value) {
|
|
22
|
+
if (value === null || value === undefined)
|
|
23
|
+
return "";
|
|
24
|
+
if (Array.isArray(value)) {
|
|
25
|
+
return value
|
|
26
|
+
.map((item) => valueToText(item))
|
|
27
|
+
.filter(Boolean)
|
|
28
|
+
.join(", ");
|
|
29
|
+
}
|
|
30
|
+
if (typeof value === "object") {
|
|
31
|
+
const record = value;
|
|
32
|
+
if (typeof record.tag === "string") {
|
|
33
|
+
return record.tag.replace(/^#/, "");
|
|
34
|
+
}
|
|
35
|
+
if ("value" in record && record.value !== undefined) {
|
|
36
|
+
return valueToText(record.value);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const text = String(value);
|
|
40
|
+
return text.trim();
|
|
41
|
+
}
|
|
42
|
+
/** Renders a multi-value Bases field (tags, multitext) as string items. */
|
|
43
|
+
export function valueToStrings(value) {
|
|
44
|
+
if (value === null || value === undefined)
|
|
45
|
+
return [];
|
|
46
|
+
if (typeof value === "object" && "value" in value)
|
|
47
|
+
return valueToStrings(value.value);
|
|
48
|
+
if (Array.isArray(value)) {
|
|
49
|
+
return value
|
|
50
|
+
.map((item) => valueToText(item))
|
|
51
|
+
.filter((text) => text.length > 0);
|
|
52
|
+
}
|
|
53
|
+
const text = valueToText(value);
|
|
54
|
+
return text.length > 0 ? [text] : [];
|
|
55
|
+
}
|
|
56
|
+
/** Converts Bases entry groups into ordered column sources. */
|
|
57
|
+
export function rawGroupValue(value) {
|
|
58
|
+
if (value === null || value === undefined)
|
|
59
|
+
return null;
|
|
60
|
+
if (typeof value === "object" && "value" in value)
|
|
61
|
+
return rawGroupValue(value.value);
|
|
62
|
+
if (typeof value === "object" &&
|
|
63
|
+
"toISO" in value &&
|
|
64
|
+
typeof value.toISO === "function")
|
|
65
|
+
return value.toISO();
|
|
66
|
+
if (Array.isArray(value))
|
|
67
|
+
return value.map(rawGroupValue);
|
|
68
|
+
if (typeof value === "object") {
|
|
69
|
+
const text = valueToText(value);
|
|
70
|
+
return !text ? null : text === "[object Object]" ? value : text;
|
|
71
|
+
}
|
|
72
|
+
return value;
|
|
73
|
+
}
|
|
74
|
+
export function groupIdentity(property, value) {
|
|
75
|
+
return (encodeURIComponent(property) +
|
|
76
|
+
":" +
|
|
77
|
+
encodeURIComponent(JSON.stringify(rawGroupValue(value)) ?? "null"));
|
|
78
|
+
}
|
|
79
|
+
export function toColumnSources(groups, groupProperty) {
|
|
80
|
+
return groups.map((group) => {
|
|
81
|
+
const keyText = valueToText(group.key ?? null);
|
|
82
|
+
return {
|
|
83
|
+
id: groupProperty
|
|
84
|
+
? groupIdentity(groupProperty, group.key)
|
|
85
|
+
: keyText || UNGROUPED_COLUMN_ID,
|
|
86
|
+
label: keyText || UNGROUPED_COLUMN_LABEL,
|
|
87
|
+
groupValue: rawGroupValue(group.key),
|
|
88
|
+
entries: group.entries,
|
|
89
|
+
};
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
/** Applies the persisted column order, keeping unknown/new columns at the end. */
|
|
93
|
+
export function applyColumnOrder(sources, columnOrder) {
|
|
94
|
+
if (!Array.isArray(columnOrder) || columnOrder.length === 0)
|
|
95
|
+
return sources;
|
|
96
|
+
const byId = new Map(sources.map((source) => [source.id, source]));
|
|
97
|
+
const ordered = [];
|
|
98
|
+
const seen = new Set();
|
|
99
|
+
for (const id of columnOrder) {
|
|
100
|
+
const source = byId.get(id) ?? sources.find((candidate) => candidate.label === id);
|
|
101
|
+
if (source && !seen.has(source.id)) {
|
|
102
|
+
ordered.push(source);
|
|
103
|
+
seen.add(source.id);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
for (const source of sources) {
|
|
107
|
+
if (!seen.has(source.id))
|
|
108
|
+
ordered.push(source);
|
|
109
|
+
}
|
|
110
|
+
return ordered;
|
|
111
|
+
}
|
|
112
|
+
/** Formats a date (epoch ms, Date, ISO string) as card meta text (e.g. "Sep 12"). */
|
|
113
|
+
export function formatDateText(value) {
|
|
114
|
+
const text = valueToText(value);
|
|
115
|
+
if (!text)
|
|
116
|
+
return "";
|
|
117
|
+
const parsed = /^\d{4}-\d{2}-\d{2}$/.test(text)
|
|
118
|
+
? new Date(Number(text.slice(0, 4)), Number(text.slice(5, 7)) - 1, Number(text.slice(8, 10)))
|
|
119
|
+
: /^\d+$/.test(text)
|
|
120
|
+
? new Date(Number(text))
|
|
121
|
+
: new Date(text);
|
|
122
|
+
return Number.isNaN(parsed.getTime())
|
|
123
|
+
? text
|
|
124
|
+
: parsed.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
|
125
|
+
}
|
|
126
|
+
const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}(?:[T ].*)?$/;
|
|
127
|
+
/** Renders a display-property value, shortening ISO dates for the meta cells. */
|
|
128
|
+
export function formatDisplayText(value) {
|
|
129
|
+
const text = valueToText(value);
|
|
130
|
+
return ISO_DATE_PATTERN.test(text) ? formatDateText(text) : text;
|
|
131
|
+
}
|
|
132
|
+
export function propertyText(value, type) {
|
|
133
|
+
if (type === "date" || type === "datetime")
|
|
134
|
+
return formatDateText(value);
|
|
135
|
+
return valueToText(value);
|
|
136
|
+
}
|
|
137
|
+
/** Maps one Bases entry onto kanban card data for a given lane colour. */
|
|
138
|
+
export function toCardData(entry, color, fields) {
|
|
139
|
+
const title = valueToText(entry.getValue(fields.titleProperty)) || entry.file.basename;
|
|
140
|
+
const display = fields.displayProperties
|
|
141
|
+
.map((propertyId) => formatDisplayText(entry.getValue(propertyId)))
|
|
142
|
+
.filter((text) => text.length > 0);
|
|
143
|
+
const stat = entry.file.stat;
|
|
144
|
+
return {
|
|
145
|
+
id: entry.id,
|
|
146
|
+
title,
|
|
147
|
+
tabLabel: fields.chipProperty
|
|
148
|
+
? propertyText(entry.getValue(fields.chipProperty), fields.propertyTypes?.[fields.chipProperty]) || title
|
|
149
|
+
: title,
|
|
150
|
+
color,
|
|
151
|
+
tags: valueToStrings(entry.getValue(fields.tagsProperty ?? "file.tags")),
|
|
152
|
+
summary: (fields.descriptionProperty
|
|
153
|
+
? valueToText(entry.getValue(fields.descriptionProperty))
|
|
154
|
+
: "") ||
|
|
155
|
+
fields.summaries?.[entry.id] ||
|
|
156
|
+
"",
|
|
157
|
+
metadata: fields.displayProperties.map((id) => ({
|
|
158
|
+
id,
|
|
159
|
+
label: fields.propertyLabels?.[id] ?? id.replace(/^(note|file)\./, ""),
|
|
160
|
+
value: propertyText(entry.getValue(id), fields.propertyTypes?.[id]) || "—",
|
|
161
|
+
})),
|
|
162
|
+
badge: fields.badgeProperty &&
|
|
163
|
+
valueToText(entry.getValue(fields.badgeProperty)) !== ""
|
|
164
|
+
? {
|
|
165
|
+
label: fields.badgeLabel?.trim() ||
|
|
166
|
+
fields.propertyLabels?.[fields.badgeProperty] ||
|
|
167
|
+
fields.badgeProperty.replace(/^(note|file)\./, ""),
|
|
168
|
+
value: propertyText(entry.getValue(fields.badgeProperty), fields.propertyTypes?.[fields.badgeProperty]),
|
|
169
|
+
color: fields.badgeColor || undefined,
|
|
170
|
+
}
|
|
171
|
+
: null,
|
|
172
|
+
createdAt: formatDateText(stat?.ctime ?? null),
|
|
173
|
+
updatedAt: formatDateText(stat?.mtime ?? null),
|
|
174
|
+
statusText: display[0] ?? "",
|
|
175
|
+
// Roll every remaining visible property into the second meta cell so
|
|
176
|
+
// adding a property to the view always surfaces on the card; "" keeps
|
|
177
|
+
// the family's legacy "No priority" placeholder from leaking through.
|
|
178
|
+
priorityText: display.slice(1).join(" · "),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
/** Builds Design Core kanban columns (and the move-target value map). */
|
|
182
|
+
export function buildKanbanColumns(input) {
|
|
183
|
+
const ordered = applyColumnOrder(toColumnSources(input.groups, input.groupProperty), input.columnOrder);
|
|
184
|
+
const groupValues = {};
|
|
185
|
+
const columns = ordered.map((source, index) => {
|
|
186
|
+
const meta = input.columnMeta[source.id] ?? input.columnMeta[source.label] ?? {};
|
|
187
|
+
const colorIndex = input.groupProperty
|
|
188
|
+
? [...source.id].reduce((hash, character) => (Math.imul(hash, 31) + character.charCodeAt(0)) >>> 0, 0)
|
|
189
|
+
: index;
|
|
190
|
+
const color = meta.color ?? KANBAN_LANE_COLORS[colorIndex % KANBAN_LANE_COLORS.length];
|
|
191
|
+
groupValues[source.id] = source.groupValue;
|
|
192
|
+
return {
|
|
193
|
+
id: source.id,
|
|
194
|
+
label: meta.title?.trim() || source.label,
|
|
195
|
+
color,
|
|
196
|
+
primary: index === 0,
|
|
197
|
+
totalCount: input.columnTotals?.[source.id],
|
|
198
|
+
cards: source.entries.map((entry) => toCardData(entry, color, {
|
|
199
|
+
...input,
|
|
200
|
+
titleProperty: input.titleProperty,
|
|
201
|
+
displayProperties: input.displayProperties,
|
|
202
|
+
})),
|
|
203
|
+
};
|
|
204
|
+
});
|
|
205
|
+
return { columns, groupValues };
|
|
206
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { untrack } from "svelte";
|
|
3
|
+
import type { App, TFile, MarkdownFileSurfaceHandle } from "@lapis-notes/api";
|
|
4
|
+
import { ScrollArea } from "@lapismd/design-core/shadcn/scroll-area";
|
|
5
|
+
|
|
6
|
+
let {
|
|
7
|
+
app,
|
|
8
|
+
file,
|
|
9
|
+
editing,
|
|
10
|
+
readOnly = false,
|
|
11
|
+
onEditingChange,
|
|
12
|
+
}: {
|
|
13
|
+
app: App;
|
|
14
|
+
file: TFile;
|
|
15
|
+
editing: boolean;
|
|
16
|
+
readOnly?: boolean;
|
|
17
|
+
onEditingChange: (editing: boolean) => void;
|
|
18
|
+
} = $props();
|
|
19
|
+
let container = $state<HTMLDivElement>();
|
|
20
|
+
let handle = $state<MarkdownFileSurfaceHandle | null>(null);
|
|
21
|
+
let error = $state("");
|
|
22
|
+
let pending: Promise<MarkdownFileSurfaceHandle> | null = null;
|
|
23
|
+
const sourcePath = $derived(file.path);
|
|
24
|
+
|
|
25
|
+
export async function flush(): Promise<boolean> {
|
|
26
|
+
const mounted = await pending;
|
|
27
|
+
return (await mounted?.flush()) ?? true;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
$effect(() => {
|
|
31
|
+
if (!container || !app.markdownFileSurfaces.available) return;
|
|
32
|
+
const host = container;
|
|
33
|
+
const path = sourcePath;
|
|
34
|
+
const source = untrack(() => app.vault.getFileByPath(path) ?? file);
|
|
35
|
+
const owner = app;
|
|
36
|
+
const editable = !readOnly;
|
|
37
|
+
let cancelled = false;
|
|
38
|
+
error = "";
|
|
39
|
+
const mounted = Promise.resolve(
|
|
40
|
+
untrack(() =>
|
|
41
|
+
owner.markdownFileSurfaces.mount({
|
|
42
|
+
containerEl: host,
|
|
43
|
+
file: source,
|
|
44
|
+
editable,
|
|
45
|
+
activation: "click",
|
|
46
|
+
returnToPreviewOnBlur: true,
|
|
47
|
+
surface: { id: "bases-card-detail", context: { path: source.path } },
|
|
48
|
+
onEditingChange: (next) => onEditingChange(next),
|
|
49
|
+
}),
|
|
50
|
+
),
|
|
51
|
+
);
|
|
52
|
+
pending = mounted;
|
|
53
|
+
void mounted
|
|
54
|
+
.then(async (surface) => {
|
|
55
|
+
if (cancelled) return;
|
|
56
|
+
handle = surface;
|
|
57
|
+
})
|
|
58
|
+
.catch((reason) => {
|
|
59
|
+
if (!cancelled)
|
|
60
|
+
error = reason instanceof Error ? reason.message : String(reason);
|
|
61
|
+
});
|
|
62
|
+
return () => {
|
|
63
|
+
cancelled = true;
|
|
64
|
+
handle = null;
|
|
65
|
+
void mounted
|
|
66
|
+
.then(async (surface) => {
|
|
67
|
+
await surface.flush();
|
|
68
|
+
await surface.dispose();
|
|
69
|
+
})
|
|
70
|
+
.catch(console.error);
|
|
71
|
+
};
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
$effect(() => {
|
|
75
|
+
const surface = handle;
|
|
76
|
+
if (!surface) return;
|
|
77
|
+
const shouldEdit = editing && !readOnly;
|
|
78
|
+
untrack(() => {
|
|
79
|
+
if (shouldEdit)
|
|
80
|
+
void Promise.resolve(surface.enter()).catch(
|
|
81
|
+
(reason) => (error = String(reason)),
|
|
82
|
+
);
|
|
83
|
+
else
|
|
84
|
+
void surface
|
|
85
|
+
.exit()
|
|
86
|
+
.then((saved) => {
|
|
87
|
+
if (!saved) onEditingChange(true);
|
|
88
|
+
})
|
|
89
|
+
.catch((reason) => (error = String(reason)));
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
</script>
|
|
93
|
+
|
|
94
|
+
{#if error}<p role="alert">{error}</p>{/if}
|
|
95
|
+
{#if !app.markdownFileSurfaces.available}
|
|
96
|
+
<p role="status">Markdown preview is unavailable.</p>
|
|
97
|
+
<button type="button" onclick={() => app.openFile(file)}>Open file</button>
|
|
98
|
+
{:else}
|
|
99
|
+
<ScrollArea class="bases-note-detail-scroll" orientation="vertical">
|
|
100
|
+
<div
|
|
101
|
+
bind:this={container}
|
|
102
|
+
class="bases-note-detail-surface"
|
|
103
|
+
data-ui-part="markdown-detail"
|
|
104
|
+
data-editing-requested={editing ? "true" : "false"}
|
|
105
|
+
></div>
|
|
106
|
+
</ScrollArea>
|
|
107
|
+
{/if}
|
|
108
|
+
|
|
109
|
+
<style>
|
|
110
|
+
:global(.bases-note-detail-scroll) {
|
|
111
|
+
height: 28rem;
|
|
112
|
+
min-height: 0;
|
|
113
|
+
}
|
|
114
|
+
.bases-note-detail-surface {
|
|
115
|
+
--ui-markdown-file-surface-background: var(
|
|
116
|
+
--ui-kanban-detail-surface,
|
|
117
|
+
transparent
|
|
118
|
+
);
|
|
119
|
+
height: 100%;
|
|
120
|
+
min-height: 0;
|
|
121
|
+
min-width: 0;
|
|
122
|
+
}
|
|
123
|
+
</style>
|