@poodle64/ui 2026.8.10 → 2026.8.12
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/README.md +180 -30
- package/dist/components/ui/app-shell/app-nav.svelte +12 -12
- package/dist/components/ui/app-shell/app-shell.svelte +14 -12
- package/dist/components/ui/app-shell/app-shell.svelte.d.ts +14 -6
- package/dist/components/ui/app-shell/types.d.ts +20 -3
- package/dist/components/ui/app-shell/types.js +29 -5
- package/dist/components/ui/collection-detail/collection-detail.svelte +169 -0
- package/dist/components/ui/collection-detail/collection-detail.svelte.d.ts +47 -0
- package/dist/components/ui/collection-detail/index.d.ts +3 -0
- package/dist/components/ui/collection-detail/index.js +2 -0
- package/dist/components/ui/document-detail/document-detail.svelte +184 -0
- package/dist/components/ui/document-detail/document-detail.svelte.d.ts +34 -0
- package/dist/components/ui/document-detail/index.d.ts +3 -0
- package/dist/components/ui/document-detail/index.js +2 -0
- package/dist/components/ui/library-browse/document-table.svelte +101 -0
- package/dist/components/ui/library-browse/document-table.svelte.d.ts +10 -0
- package/dist/components/ui/library-browse/facet-rail.svelte +62 -0
- package/dist/components/ui/library-browse/facet-rail.svelte.d.ts +8 -0
- package/dist/components/ui/library-browse/index.d.ts +3 -0
- package/dist/components/ui/library-browse/index.js +2 -0
- package/dist/components/ui/library-browse/library-browse.svelte +235 -0
- package/dist/components/ui/library-browse/library-browse.svelte.d.ts +44 -0
- package/dist/components/ui/library-browse/types.d.ts +101 -0
- package/dist/components/ui/library-browse/types.js +1 -0
- package/dist/components/ui/schema-form/context.d.ts +31 -0
- package/dist/components/ui/schema-form/context.js +10 -0
- package/dist/components/ui/schema-form/data.d.ts +42 -0
- package/dist/components/ui/schema-form/data.js +97 -0
- package/dist/components/ui/schema-form/dispatch.d.ts +35 -0
- package/dist/components/ui/schema-form/dispatch.js +139 -0
- package/dist/components/ui/schema-form/index.d.ts +5 -0
- package/dist/components/ui/schema-form/index.js +5 -0
- package/dist/components/ui/schema-form/schema-form-control.svelte +146 -0
- package/dist/components/ui/schema-form/schema-form-control.svelte.d.ts +7 -0
- package/dist/components/ui/schema-form/schema-form-element.svelte +105 -0
- package/dist/components/ui/schema-form/schema-form-element.svelte.d.ts +7 -0
- package/dist/components/ui/schema-form/schema-form-widget.svelte +169 -0
- package/dist/components/ui/schema-form/schema-form-widget.svelte.d.ts +18 -0
- package/dist/components/ui/schema-form/schema-form.svelte +153 -0
- package/dist/components/ui/schema-form/schema-form.svelte.d.ts +4 -0
- package/dist/components/ui/schema-form/types.d.ts +61 -0
- package/dist/components/ui/schema-form/types.js +21 -0
- package/dist/components/ui/schema-form/widgets/radio-field.svelte +48 -0
- package/dist/components/ui/schema-form/widgets/radio-field.svelte.d.ts +15 -0
- package/dist/components/ui/schema-form/widgets/slider-field.svelte +51 -0
- package/dist/components/ui/schema-form/widgets/slider-field.svelte.d.ts +13 -0
- package/dist/components/ui/schema-form/widgets/tags-field.svelte +98 -0
- package/dist/components/ui/schema-form/widgets/tags-field.svelte.d.ts +12 -0
- package/dist/components/ui/schema-form/widgets/unknown-field.svelte +123 -0
- package/dist/components/ui/schema-form/widgets/unknown-field.svelte.d.ts +20 -0
- package/dist/components/ui/search-results/index.d.ts +3 -0
- package/dist/components/ui/search-results/index.js +2 -0
- package/dist/components/ui/search-results/search-results.svelte +183 -0
- package/dist/components/ui/search-results/search-results.svelte.d.ts +40 -0
- package/package.json +2 -1
- package/registry/component-map.json +52 -3
- package/registry/component-map.md +20 -1
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* CollectionDetail — one collection's surface: its identity (name, meta,
|
|
4
|
+
* state), an at-a-glance stat list, and the documents it holds.
|
|
5
|
+
*
|
|
6
|
+
* Fixed props over plain data (#30): the page fetches, maps its responses
|
|
7
|
+
* into the shared library vocabulary, and owns the pager offset. Anything
|
|
8
|
+
* app-specific — a config form, an operations tab, action buttons — comes
|
|
9
|
+
* in through `actions` (the identity panel's footer) and `children`
|
|
10
|
+
* (sections rendered after the documents), so the app extends the surface
|
|
11
|
+
* without forking it.
|
|
12
|
+
*/
|
|
13
|
+
import type { Snippet } from 'svelte';
|
|
14
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
15
|
+
import { cn, type WithElementRef } from '../../../utils.js';
|
|
16
|
+
import Button from '../button/button.svelte';
|
|
17
|
+
import DetailPanel from '../detail-panel/detail-panel.svelte';
|
|
18
|
+
import Panel from '../panel/panel.svelte';
|
|
19
|
+
import StatList from '../stat-list/stat-list.svelte';
|
|
20
|
+
import type { StatItem } from '../stat-list/stat-list.svelte';
|
|
21
|
+
import EmptyState from '../empty-state/empty-state.svelte';
|
|
22
|
+
import ErrorState from '../error-state/error-state.svelte';
|
|
23
|
+
import LoadingState from '../loading-state/loading-state.svelte';
|
|
24
|
+
import DocumentTable from '../library-browse/document-table.svelte';
|
|
25
|
+
import type { LibraryCollection, LibraryDocument } from '../library-browse/types.js';
|
|
26
|
+
|
|
27
|
+
let {
|
|
28
|
+
collection,
|
|
29
|
+
stats = [],
|
|
30
|
+
documents = [],
|
|
31
|
+
documentsTotal,
|
|
32
|
+
offset = 0,
|
|
33
|
+
limit = 25,
|
|
34
|
+
loading = false,
|
|
35
|
+
error = null,
|
|
36
|
+
documentsLoading = false,
|
|
37
|
+
documentsError = null,
|
|
38
|
+
emptyTitle = 'No documents in this collection yet',
|
|
39
|
+
emptyDescription,
|
|
40
|
+
notFoundTitle = 'Collection not found',
|
|
41
|
+
notFoundDescription,
|
|
42
|
+
onPageChange,
|
|
43
|
+
onRetry,
|
|
44
|
+
documentHref,
|
|
45
|
+
onOpenDocument,
|
|
46
|
+
actions,
|
|
47
|
+
children,
|
|
48
|
+
ref = $bindable(null),
|
|
49
|
+
class: className,
|
|
50
|
+
...restProps
|
|
51
|
+
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
|
|
52
|
+
collection: LibraryCollection | null;
|
|
53
|
+
/** At-a-glance figures for the collection (document count, indexed, …). */
|
|
54
|
+
stats?: StatItem[];
|
|
55
|
+
documents?: LibraryDocument[];
|
|
56
|
+
/** Total documents across all pages; defaults to the rows given. */
|
|
57
|
+
documentsTotal?: number;
|
|
58
|
+
offset?: number;
|
|
59
|
+
limit?: number;
|
|
60
|
+
/** The collection itself is still loading. */
|
|
61
|
+
loading?: boolean;
|
|
62
|
+
error?: string | null;
|
|
63
|
+
/** The document list loads independently of the identity. */
|
|
64
|
+
documentsLoading?: boolean;
|
|
65
|
+
documentsError?: string | null;
|
|
66
|
+
emptyTitle?: string;
|
|
67
|
+
emptyDescription?: string;
|
|
68
|
+
notFoundTitle?: string;
|
|
69
|
+
notFoundDescription?: string;
|
|
70
|
+
onPageChange?: (offset: number) => void;
|
|
71
|
+
onRetry?: () => void;
|
|
72
|
+
documentHref?: (doc: LibraryDocument) => string;
|
|
73
|
+
onOpenDocument?: (doc: LibraryDocument) => void;
|
|
74
|
+
/** App-specific controls, rendered in the identity panel's footer. */
|
|
75
|
+
actions?: Snippet;
|
|
76
|
+
/** App-specific sections rendered after the documents. */
|
|
77
|
+
children?: Snippet;
|
|
78
|
+
} = $props();
|
|
79
|
+
|
|
80
|
+
const shownTotal = $derived(documentsTotal ?? documents.length);
|
|
81
|
+
// Floored at 1 so a consumer's limit: 0 cannot put Infinity in the pager.
|
|
82
|
+
const pageSize = $derived(Math.max(1, limit));
|
|
83
|
+
const pageCount = $derived(Math.max(1, Math.ceil(shownTotal / pageSize)));
|
|
84
|
+
const page = $derived(Math.floor(offset / pageSize) + 1);
|
|
85
|
+
</script>
|
|
86
|
+
|
|
87
|
+
<div bind:this={ref} class={cn('flex flex-col gap-4', className)} {...restProps}>
|
|
88
|
+
{#if loading}
|
|
89
|
+
<LoadingState message="Loading the collection…" />
|
|
90
|
+
{:else if error}
|
|
91
|
+
<ErrorState message={error}>
|
|
92
|
+
{#snippet action()}
|
|
93
|
+
{#if onRetry}
|
|
94
|
+
<Button variant="outline" onclick={onRetry}>Retry</Button>
|
|
95
|
+
{/if}
|
|
96
|
+
{/snippet}
|
|
97
|
+
</ErrorState>
|
|
98
|
+
{:else if !collection}
|
|
99
|
+
<EmptyState title={notFoundTitle} description={notFoundDescription} />
|
|
100
|
+
{:else}
|
|
101
|
+
<DetailPanel
|
|
102
|
+
eyebrow="Collection"
|
|
103
|
+
title={collection.name}
|
|
104
|
+
titleFace="display"
|
|
105
|
+
status={collection.badge?.status}
|
|
106
|
+
statusLabel={collection.badge?.label}
|
|
107
|
+
footer={actions}
|
|
108
|
+
>
|
|
109
|
+
{#if collection.subtitle}
|
|
110
|
+
<p class="text-muted-foreground text-sm">{collection.subtitle}</p>
|
|
111
|
+
{/if}
|
|
112
|
+
{#if collection.description}
|
|
113
|
+
<p class="mt-2 max-w-prose text-sm">{collection.description}</p>
|
|
114
|
+
{/if}
|
|
115
|
+
{#if !collection.subtitle && !collection.description}
|
|
116
|
+
<p class="text-muted-foreground text-sm">No description recorded.</p>
|
|
117
|
+
{/if}
|
|
118
|
+
</DetailPanel>
|
|
119
|
+
|
|
120
|
+
{#if stats.length > 0}
|
|
121
|
+
<StatList items={stats} />
|
|
122
|
+
{/if}
|
|
123
|
+
|
|
124
|
+
{#if documentsLoading}
|
|
125
|
+
<LoadingState message="Loading the documents…" />
|
|
126
|
+
{:else if documentsError}
|
|
127
|
+
<ErrorState message={documentsError}>
|
|
128
|
+
{#snippet action()}
|
|
129
|
+
{#if onRetry}
|
|
130
|
+
<Button variant="outline" onclick={onRetry}>Retry</Button>
|
|
131
|
+
{/if}
|
|
132
|
+
{/snippet}
|
|
133
|
+
</ErrorState>
|
|
134
|
+
{:else if documents.length === 0}
|
|
135
|
+
<EmptyState title={emptyTitle} description={emptyDescription} />
|
|
136
|
+
{:else}
|
|
137
|
+
<Panel title="Documents" subtitle="{shownTotal} document{shownTotal === 1 ? '' : 's'}">
|
|
138
|
+
<div class="flex flex-col gap-3">
|
|
139
|
+
<DocumentTable {documents} {documentHref} onOpen={onOpenDocument} />
|
|
140
|
+
{#if onPageChange && shownTotal > pageSize}
|
|
141
|
+
<div class="flex flex-wrap items-center justify-end gap-2">
|
|
142
|
+
<Button
|
|
143
|
+
variant="outline"
|
|
144
|
+
size="sm"
|
|
145
|
+
disabled={offset === 0}
|
|
146
|
+
onclick={() => onPageChange(Math.max(0, offset - pageSize))}
|
|
147
|
+
>
|
|
148
|
+
Previous
|
|
149
|
+
</Button>
|
|
150
|
+
<span class="text-muted-foreground text-xs">Page {page} of {pageCount}</span>
|
|
151
|
+
<Button
|
|
152
|
+
variant="outline"
|
|
153
|
+
size="sm"
|
|
154
|
+
disabled={offset + pageSize >= shownTotal}
|
|
155
|
+
onclick={() => onPageChange(offset + pageSize)}
|
|
156
|
+
>
|
|
157
|
+
Next
|
|
158
|
+
</Button>
|
|
159
|
+
</div>
|
|
160
|
+
{/if}
|
|
161
|
+
</div>
|
|
162
|
+
</Panel>
|
|
163
|
+
{/if}
|
|
164
|
+
|
|
165
|
+
{#if children}
|
|
166
|
+
{@render children()}
|
|
167
|
+
{/if}
|
|
168
|
+
{/if}
|
|
169
|
+
</div>
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CollectionDetail — one collection's surface: its identity (name, meta,
|
|
3
|
+
* state), an at-a-glance stat list, and the documents it holds.
|
|
4
|
+
*
|
|
5
|
+
* Fixed props over plain data (#30): the page fetches, maps its responses
|
|
6
|
+
* into the shared library vocabulary, and owns the pager offset. Anything
|
|
7
|
+
* app-specific — a config form, an operations tab, action buttons — comes
|
|
8
|
+
* in through `actions` (the identity panel's footer) and `children`
|
|
9
|
+
* (sections rendered after the documents), so the app extends the surface
|
|
10
|
+
* without forking it.
|
|
11
|
+
*/
|
|
12
|
+
import type { Snippet } from 'svelte';
|
|
13
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
14
|
+
import { type WithElementRef } from '../../../utils.js';
|
|
15
|
+
import type { StatItem } from '../stat-list/stat-list.svelte';
|
|
16
|
+
import type { LibraryCollection, LibraryDocument } from '../library-browse/types.js';
|
|
17
|
+
type $$ComponentProps = WithElementRef<HTMLAttributes<HTMLDivElement>> & {
|
|
18
|
+
collection: LibraryCollection | null;
|
|
19
|
+
/** At-a-glance figures for the collection (document count, indexed, …). */
|
|
20
|
+
stats?: StatItem[];
|
|
21
|
+
documents?: LibraryDocument[];
|
|
22
|
+
/** Total documents across all pages; defaults to the rows given. */
|
|
23
|
+
documentsTotal?: number;
|
|
24
|
+
offset?: number;
|
|
25
|
+
limit?: number;
|
|
26
|
+
/** The collection itself is still loading. */
|
|
27
|
+
loading?: boolean;
|
|
28
|
+
error?: string | null;
|
|
29
|
+
/** The document list loads independently of the identity. */
|
|
30
|
+
documentsLoading?: boolean;
|
|
31
|
+
documentsError?: string | null;
|
|
32
|
+
emptyTitle?: string;
|
|
33
|
+
emptyDescription?: string;
|
|
34
|
+
notFoundTitle?: string;
|
|
35
|
+
notFoundDescription?: string;
|
|
36
|
+
onPageChange?: (offset: number) => void;
|
|
37
|
+
onRetry?: () => void;
|
|
38
|
+
documentHref?: (doc: LibraryDocument) => string;
|
|
39
|
+
onOpenDocument?: (doc: LibraryDocument) => void;
|
|
40
|
+
/** App-specific controls, rendered in the identity panel's footer. */
|
|
41
|
+
actions?: Snippet;
|
|
42
|
+
/** App-specific sections rendered after the documents. */
|
|
43
|
+
children?: Snippet;
|
|
44
|
+
};
|
|
45
|
+
declare const CollectionDetail: import("svelte").Component<$$ComponentProps, {}, "ref">;
|
|
46
|
+
type CollectionDetail = ReturnType<typeof CollectionDetail>;
|
|
47
|
+
export default CollectionDetail;
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* DocumentDetail — one document's surface: its identity fields, recorded
|
|
4
|
+
* locations, tags, and collection memberships.
|
|
5
|
+
*
|
|
6
|
+
* Fixed props over plain data (#30). Sections render only when their data
|
|
7
|
+
* is present, so a consumer whose documents carry no locations never shows
|
|
8
|
+
* an empty heading. App-specific work — retract/re-extract buttons, an
|
|
9
|
+
* operations history — comes in through `actions` (the panel's footer) and
|
|
10
|
+
* `children` (sections after the panel); membership links are the app's
|
|
11
|
+
* own routed links via `collectionHref`, because this package has no
|
|
12
|
+
* router.
|
|
13
|
+
*/
|
|
14
|
+
import type { Snippet } from 'svelte';
|
|
15
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
16
|
+
import { cn, type WithElementRef } from '../../../utils.js';
|
|
17
|
+
import Button from '../button/button.svelte';
|
|
18
|
+
import { Badge } from '../badge/index.js';
|
|
19
|
+
import DetailPanel from '../detail-panel/detail-panel.svelte';
|
|
20
|
+
import StatusBadge from '../status-badge/status-badge.svelte';
|
|
21
|
+
import EmptyState from '../empty-state/empty-state.svelte';
|
|
22
|
+
import ErrorState from '../error-state/error-state.svelte';
|
|
23
|
+
import LoadingState from '../loading-state/loading-state.svelte';
|
|
24
|
+
import type { LibraryDocumentDetail, LibraryMembership } from '../library-browse/types.js';
|
|
25
|
+
|
|
26
|
+
let {
|
|
27
|
+
document,
|
|
28
|
+
loading = false,
|
|
29
|
+
error = null,
|
|
30
|
+
notFoundTitle = 'Document not found',
|
|
31
|
+
notFoundDescription = 'This document is not in the catalogue.',
|
|
32
|
+
collectionHref,
|
|
33
|
+
onOpenCollection,
|
|
34
|
+
onRetry,
|
|
35
|
+
actions,
|
|
36
|
+
children,
|
|
37
|
+
ref = $bindable(null),
|
|
38
|
+
class: className,
|
|
39
|
+
...restProps
|
|
40
|
+
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
|
|
41
|
+
document: LibraryDocumentDetail | null;
|
|
42
|
+
loading?: boolean;
|
|
43
|
+
error?: string | null;
|
|
44
|
+
notFoundTitle?: string;
|
|
45
|
+
notFoundDescription?: string;
|
|
46
|
+
/** The app's own routed link per membership. */
|
|
47
|
+
collectionHref?: (membership: LibraryMembership) => string;
|
|
48
|
+
onOpenCollection?: (membership: LibraryMembership) => void;
|
|
49
|
+
onRetry?: () => void;
|
|
50
|
+
/** App-specific controls, rendered in the panel's footer. */
|
|
51
|
+
actions?: Snippet;
|
|
52
|
+
/** App-specific sections rendered after the panel. */
|
|
53
|
+
children?: Snippet;
|
|
54
|
+
} = $props();
|
|
55
|
+
</script>
|
|
56
|
+
|
|
57
|
+
<div bind:this={ref} class={cn('flex flex-col gap-4', className)} {...restProps}>
|
|
58
|
+
{#if loading}
|
|
59
|
+
<LoadingState message="Loading the document…" />
|
|
60
|
+
{:else if error}
|
|
61
|
+
<ErrorState message={error}>
|
|
62
|
+
{#snippet action()}
|
|
63
|
+
{#if onRetry}
|
|
64
|
+
<Button variant="outline" onclick={onRetry}>Retry</Button>
|
|
65
|
+
{/if}
|
|
66
|
+
{/snippet}
|
|
67
|
+
</ErrorState>
|
|
68
|
+
{:else if !document}
|
|
69
|
+
<EmptyState title={notFoundTitle} description={notFoundDescription} />
|
|
70
|
+
{:else}
|
|
71
|
+
<DetailPanel eyebrow="Document" title={document.title} titleFace="display" footer={actions}>
|
|
72
|
+
<div class="grid gap-4 sm:grid-cols-2">
|
|
73
|
+
{#if document.fields?.length}
|
|
74
|
+
<div>
|
|
75
|
+
<h3 class="text-muted-foreground text-2xs mb-2 font-semibold tracking-wide uppercase">
|
|
76
|
+
Details
|
|
77
|
+
</h3>
|
|
78
|
+
<dl class="space-y-1 text-sm">
|
|
79
|
+
{#each document.fields as field (field.label)}
|
|
80
|
+
<div class="flex justify-between gap-4">
|
|
81
|
+
<dt class="text-muted-foreground">{field.label}</dt>
|
|
82
|
+
<dd class={field.mono ? 'truncate font-mono text-xs' : 'text-right'}>
|
|
83
|
+
{field.value}
|
|
84
|
+
</dd>
|
|
85
|
+
</div>
|
|
86
|
+
{/each}
|
|
87
|
+
</dl>
|
|
88
|
+
</div>
|
|
89
|
+
{/if}
|
|
90
|
+
|
|
91
|
+
{#if document.locations?.length}
|
|
92
|
+
<div>
|
|
93
|
+
<h3 class="text-muted-foreground text-2xs mb-2 font-semibold tracking-wide uppercase">
|
|
94
|
+
Locations
|
|
95
|
+
</h3>
|
|
96
|
+
<ul class="space-y-1 text-sm">
|
|
97
|
+
{#each document.locations as location (location.path)}
|
|
98
|
+
<li class="flex items-center justify-between gap-2">
|
|
99
|
+
<span class="truncate font-mono text-xs">{location.path}</span>
|
|
100
|
+
<div class="flex shrink-0 items-center gap-1">
|
|
101
|
+
{#if location.primary}
|
|
102
|
+
<Badge variant="secondary">primary</Badge>
|
|
103
|
+
{/if}
|
|
104
|
+
{#if location.badge}
|
|
105
|
+
<StatusBadge status={location.badge.status} label={location.badge.label} />
|
|
106
|
+
{/if}
|
|
107
|
+
</div>
|
|
108
|
+
</li>
|
|
109
|
+
{/each}
|
|
110
|
+
</ul>
|
|
111
|
+
</div>
|
|
112
|
+
{/if}
|
|
113
|
+
|
|
114
|
+
{#if document.tags?.length}
|
|
115
|
+
<div>
|
|
116
|
+
<h3 class="text-muted-foreground text-2xs mb-2 font-semibold tracking-wide uppercase">
|
|
117
|
+
Tags
|
|
118
|
+
</h3>
|
|
119
|
+
<div class="flex flex-wrap gap-1.5">
|
|
120
|
+
{#each document.tags as tag (tag)}
|
|
121
|
+
<Badge variant="outline">{tag}</Badge>
|
|
122
|
+
{/each}
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
{/if}
|
|
126
|
+
|
|
127
|
+
{#if document.memberships?.length}
|
|
128
|
+
<div>
|
|
129
|
+
<h3 class="text-muted-foreground text-2xs mb-2 font-semibold tracking-wide uppercase">
|
|
130
|
+
Collections
|
|
131
|
+
</h3>
|
|
132
|
+
<div class="flex flex-wrap gap-1.5">
|
|
133
|
+
{#each document.memberships as membership (membership.id)}
|
|
134
|
+
{#if collectionHref}
|
|
135
|
+
<a
|
|
136
|
+
href={collectionHref(membership)}
|
|
137
|
+
class="inline-flex items-center gap-1"
|
|
138
|
+
onclick={() => onOpenCollection?.(membership)}
|
|
139
|
+
>
|
|
140
|
+
<Badge variant="outline">{membership.name}</Badge>
|
|
141
|
+
{#if membership.badge}
|
|
142
|
+
<StatusBadge
|
|
143
|
+
status={membership.badge.status}
|
|
144
|
+
label={membership.badge.label}
|
|
145
|
+
/>
|
|
146
|
+
{/if}
|
|
147
|
+
</a>
|
|
148
|
+
{:else if onOpenCollection}
|
|
149
|
+
<button
|
|
150
|
+
type="button"
|
|
151
|
+
class="inline-flex items-center gap-1"
|
|
152
|
+
onclick={() => onOpenCollection(membership)}
|
|
153
|
+
>
|
|
154
|
+
<Badge variant="outline">{membership.name}</Badge>
|
|
155
|
+
{#if membership.badge}
|
|
156
|
+
<StatusBadge
|
|
157
|
+
status={membership.badge.status}
|
|
158
|
+
label={membership.badge.label}
|
|
159
|
+
/>
|
|
160
|
+
{/if}
|
|
161
|
+
</button>
|
|
162
|
+
{:else}
|
|
163
|
+
<span class="inline-flex items-center gap-1">
|
|
164
|
+
<Badge variant="outline">{membership.name}</Badge>
|
|
165
|
+
{#if membership.badge}
|
|
166
|
+
<StatusBadge
|
|
167
|
+
status={membership.badge.status}
|
|
168
|
+
label={membership.badge.label}
|
|
169
|
+
/>
|
|
170
|
+
{/if}
|
|
171
|
+
</span>
|
|
172
|
+
{/if}
|
|
173
|
+
{/each}
|
|
174
|
+
</div>
|
|
175
|
+
</div>
|
|
176
|
+
{/if}
|
|
177
|
+
</div>
|
|
178
|
+
</DetailPanel>
|
|
179
|
+
|
|
180
|
+
{#if children}
|
|
181
|
+
{@render children()}
|
|
182
|
+
{/if}
|
|
183
|
+
{/if}
|
|
184
|
+
</div>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DocumentDetail — one document's surface: its identity fields, recorded
|
|
3
|
+
* locations, tags, and collection memberships.
|
|
4
|
+
*
|
|
5
|
+
* Fixed props over plain data (#30). Sections render only when their data
|
|
6
|
+
* is present, so a consumer whose documents carry no locations never shows
|
|
7
|
+
* an empty heading. App-specific work — retract/re-extract buttons, an
|
|
8
|
+
* operations history — comes in through `actions` (the panel's footer) and
|
|
9
|
+
* `children` (sections after the panel); membership links are the app's
|
|
10
|
+
* own routed links via `collectionHref`, because this package has no
|
|
11
|
+
* router.
|
|
12
|
+
*/
|
|
13
|
+
import type { Snippet } from 'svelte';
|
|
14
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
15
|
+
import { type WithElementRef } from '../../../utils.js';
|
|
16
|
+
import type { LibraryDocumentDetail, LibraryMembership } from '../library-browse/types.js';
|
|
17
|
+
type $$ComponentProps = WithElementRef<HTMLAttributes<HTMLDivElement>> & {
|
|
18
|
+
document: LibraryDocumentDetail | null;
|
|
19
|
+
loading?: boolean;
|
|
20
|
+
error?: string | null;
|
|
21
|
+
notFoundTitle?: string;
|
|
22
|
+
notFoundDescription?: string;
|
|
23
|
+
/** The app's own routed link per membership. */
|
|
24
|
+
collectionHref?: (membership: LibraryMembership) => string;
|
|
25
|
+
onOpenCollection?: (membership: LibraryMembership) => void;
|
|
26
|
+
onRetry?: () => void;
|
|
27
|
+
/** App-specific controls, rendered in the panel's footer. */
|
|
28
|
+
actions?: Snippet;
|
|
29
|
+
/** App-specific sections rendered after the panel. */
|
|
30
|
+
children?: Snippet;
|
|
31
|
+
};
|
|
32
|
+
declare const DocumentDetail: import("svelte").Component<$$ComponentProps, {}, "ref">;
|
|
33
|
+
type DocumentDetail = ReturnType<typeof DocumentDetail>;
|
|
34
|
+
export default DocumentDetail;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// The one catalogue table, shared by LibraryBrowse and CollectionDetail so
|
|
3
|
+
// the two surfaces cannot drift apart. Columns come and go with the data —
|
|
4
|
+
// a consumer that passes no `collections` never sees an empty column —
|
|
5
|
+
// which is what lets one table serve both the whole-catalogue view and a
|
|
6
|
+
// single collection's slice.
|
|
7
|
+
import {
|
|
8
|
+
Table as TableRoot,
|
|
9
|
+
TableBody,
|
|
10
|
+
TableCell,
|
|
11
|
+
TableHead,
|
|
12
|
+
TableHeader,
|
|
13
|
+
TableRow
|
|
14
|
+
} from '../table/index.js';
|
|
15
|
+
import StatusBadge from '../status-badge/status-badge.svelte';
|
|
16
|
+
import type { LibraryDocument } from './types.js';
|
|
17
|
+
|
|
18
|
+
let {
|
|
19
|
+
documents,
|
|
20
|
+
documentHref,
|
|
21
|
+
onOpen
|
|
22
|
+
}: {
|
|
23
|
+
documents: LibraryDocument[];
|
|
24
|
+
/** The app's own routed link per row; this package has no router of its own. */
|
|
25
|
+
documentHref?: (doc: LibraryDocument) => string;
|
|
26
|
+
onOpen?: (doc: LibraryDocument) => void;
|
|
27
|
+
} = $props();
|
|
28
|
+
|
|
29
|
+
const hasTags = $derived(documents.some((d) => d.tags?.length));
|
|
30
|
+
const hasCollections = $derived(documents.some((d) => d.collections?.length));
|
|
31
|
+
const hasBadges = $derived(documents.some((d) => d.badges?.length));
|
|
32
|
+
</script>
|
|
33
|
+
|
|
34
|
+
<div class="rounded-md border">
|
|
35
|
+
<TableRoot>
|
|
36
|
+
<TableHeader>
|
|
37
|
+
<TableRow>
|
|
38
|
+
<TableHead>Title</TableHead>
|
|
39
|
+
{#if hasTags}
|
|
40
|
+
<TableHead class="w-40">Tags</TableHead>
|
|
41
|
+
{/if}
|
|
42
|
+
{#if hasCollections}
|
|
43
|
+
<TableHead class="w-40">Collections</TableHead>
|
|
44
|
+
{/if}
|
|
45
|
+
{#if hasBadges}
|
|
46
|
+
<TableHead class="w-40 text-center">Status</TableHead>
|
|
47
|
+
{/if}
|
|
48
|
+
</TableRow>
|
|
49
|
+
</TableHeader>
|
|
50
|
+
<TableBody>
|
|
51
|
+
{#each documents as doc (doc.id)}
|
|
52
|
+
<TableRow>
|
|
53
|
+
<TableCell>
|
|
54
|
+
{#if documentHref}
|
|
55
|
+
<a
|
|
56
|
+
href={documentHref(doc)}
|
|
57
|
+
class="font-medium hover:underline"
|
|
58
|
+
onclick={() => onOpen?.(doc)}
|
|
59
|
+
>
|
|
60
|
+
{doc.title}
|
|
61
|
+
</a>
|
|
62
|
+
{:else if onOpen}
|
|
63
|
+
<button
|
|
64
|
+
type="button"
|
|
65
|
+
class="text-left font-medium hover:underline"
|
|
66
|
+
onclick={() => onOpen(doc)}
|
|
67
|
+
>
|
|
68
|
+
{doc.title}
|
|
69
|
+
</button>
|
|
70
|
+
{:else}
|
|
71
|
+
<span class="font-medium">{doc.title}</span>
|
|
72
|
+
{/if}
|
|
73
|
+
</TableCell>
|
|
74
|
+
{#if hasTags}
|
|
75
|
+
<TableCell class="text-muted-foreground text-xs">
|
|
76
|
+
{doc.tags?.join(', ') || '—'}
|
|
77
|
+
</TableCell>
|
|
78
|
+
{/if}
|
|
79
|
+
{#if hasCollections}
|
|
80
|
+
<TableCell class="text-muted-foreground text-xs">
|
|
81
|
+
{doc.collections?.join(', ') || '—'}
|
|
82
|
+
</TableCell>
|
|
83
|
+
{/if}
|
|
84
|
+
{#if hasBadges}
|
|
85
|
+
<TableCell class="text-center">
|
|
86
|
+
<div class="flex flex-wrap justify-center gap-1">
|
|
87
|
+
<!-- Keyed by index: LibraryBadge carries no id, and two memberships
|
|
88
|
+
can legitimately map to the same label+status. -->
|
|
89
|
+
{#each doc.badges ?? [] as badge, i (i)}
|
|
90
|
+
<StatusBadge status={badge.status} label={badge.label} />
|
|
91
|
+
{:else}
|
|
92
|
+
<span class="text-muted-foreground text-xs">—</span>
|
|
93
|
+
{/each}
|
|
94
|
+
</div>
|
|
95
|
+
</TableCell>
|
|
96
|
+
{/if}
|
|
97
|
+
</TableRow>
|
|
98
|
+
{/each}
|
|
99
|
+
</TableBody>
|
|
100
|
+
</TableRoot>
|
|
101
|
+
</div>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { LibraryDocument } from './types.js';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
documents: LibraryDocument[];
|
|
4
|
+
/** The app's own routed link per row; this package has no router of its own. */
|
|
5
|
+
documentHref?: (doc: LibraryDocument) => string;
|
|
6
|
+
onOpen?: (doc: LibraryDocument) => void;
|
|
7
|
+
};
|
|
8
|
+
declare const DocumentTable: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
9
|
+
type DocumentTable = ReturnType<typeof DocumentTable>;
|
|
10
|
+
export default DocumentTable;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// The facet rail beside the catalogue. Selection state belongs to the page
|
|
3
|
+
// (the app re-queries its own backend on change); this only renders the
|
|
4
|
+
// dimensions it is given and hands the next selection back.
|
|
5
|
+
import type { LibraryFacet } from './types.js';
|
|
6
|
+
|
|
7
|
+
let {
|
|
8
|
+
facets,
|
|
9
|
+
onChange
|
|
10
|
+
}: {
|
|
11
|
+
facets: LibraryFacet[];
|
|
12
|
+
onChange?: (key: string, selected: string[]) => void;
|
|
13
|
+
} = $props();
|
|
14
|
+
|
|
15
|
+
function toggle(facet: LibraryFacet, value: string) {
|
|
16
|
+
const has = facet.selected.includes(value);
|
|
17
|
+
if (facet.multiple) {
|
|
18
|
+
onChange?.(
|
|
19
|
+
facet.key,
|
|
20
|
+
has ? facet.selected.filter((v) => v !== value) : [...facet.selected, value]
|
|
21
|
+
);
|
|
22
|
+
} else {
|
|
23
|
+
onChange?.(facet.key, has ? [] : [value]);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
</script>
|
|
27
|
+
|
|
28
|
+
<div class="space-y-4">
|
|
29
|
+
{#each facets as facet (facet.key)}
|
|
30
|
+
<div>
|
|
31
|
+
<h3 class="text-muted-foreground text-2xs mb-1 font-semibold tracking-wide uppercase">
|
|
32
|
+
{facet.label}
|
|
33
|
+
</h3>
|
|
34
|
+
{#if facet.options.length === 0}
|
|
35
|
+
<p class="text-muted-foreground text-xs">Populates once the catalogue holds documents.</p>
|
|
36
|
+
{:else}
|
|
37
|
+
<ul class="space-y-0.5">
|
|
38
|
+
{#each facet.options as option (option.value)}
|
|
39
|
+
{@const active = facet.selected.includes(option.value)}
|
|
40
|
+
<li>
|
|
41
|
+
<button
|
|
42
|
+
type="button"
|
|
43
|
+
aria-pressed={active}
|
|
44
|
+
class="hover:bg-muted flex w-full items-center justify-between rounded px-1.5 py-1 text-left text-sm {active
|
|
45
|
+
? 'bg-muted font-medium'
|
|
46
|
+
: ''}"
|
|
47
|
+
onclick={() => toggle(facet, option.value)}
|
|
48
|
+
>
|
|
49
|
+
<span class="truncate">{option.label ?? option.value}</span>
|
|
50
|
+
{#if option.count !== undefined}
|
|
51
|
+
<span class="text-muted-foreground font-mono text-xs tabular-nums">
|
|
52
|
+
{option.count}
|
|
53
|
+
</span>
|
|
54
|
+
{/if}
|
|
55
|
+
</button>
|
|
56
|
+
</li>
|
|
57
|
+
{/each}
|
|
58
|
+
</ul>
|
|
59
|
+
{/if}
|
|
60
|
+
</div>
|
|
61
|
+
{/each}
|
|
62
|
+
</div>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { LibraryFacet } from './types.js';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
facets: LibraryFacet[];
|
|
4
|
+
onChange?: (key: string, selected: string[]) => void;
|
|
5
|
+
};
|
|
6
|
+
declare const FacetRail: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
7
|
+
type FacetRail = ReturnType<typeof FacetRail>;
|
|
8
|
+
export default FacetRail;
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { default as LibraryBrowse } from './library-browse.svelte';
|
|
2
|
+
export { default } from './library-browse.svelte';
|
|
3
|
+
export type { LibraryBadge, LibraryDocument, LibraryFacet, LibraryFacetOption, LibraryField, LibraryLocation, LibraryMembership, LibraryDocumentDetail, LibraryCollection, LibrarySearchResult, SearchSnippetSegment } from './types.js';
|