mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Add snippet metadata panel, duplicate, and immediate-load preview (M4.5)
This commit is contained in:
@@ -78,6 +78,14 @@ export function LivePreview() {
|
||||
// Datasets feed reference resolution (spec §04 step 1). Re-rendering on a
|
||||
// dataset change keeps a referencing chart live as its data is edited.
|
||||
const datasets = useDatasetStore(useShallow((s) => s.datasets));
|
||||
// A programmatic buffer load (select/create/revert/hydrate — `bufferEpoch`) or a
|
||||
// Draft/Published switch (`editorView`) must render *immediately*, not after the
|
||||
// typing debounce (spec §03C). Keystrokes change only `shownText`, so when these
|
||||
// two are unchanged the change is typing and the debounce applies.
|
||||
const bufferEpoch = useSnippetStore((s) => s.bufferEpoch);
|
||||
const editorView = useSnippetStore((s) => s.editorView);
|
||||
// Seed with a sentinel epoch so the very first paint counts as a load (immediate).
|
||||
const lastLoadRef = useRef({ bufferEpoch: -1, editorView });
|
||||
const error = usePreviewStore((s) => s.error);
|
||||
const setError = usePreviewStore((s) => s.setError);
|
||||
|
||||
@@ -90,6 +98,12 @@ export function LivePreview() {
|
||||
if (!node) return;
|
||||
const text = shownText.trim();
|
||||
|
||||
// Immediate on snippet load / view switch (spec §03C), debounced while typing.
|
||||
const prevLoad = lastLoadRef.current;
|
||||
const immediate = bufferEpoch !== prevLoad.bufferEpoch || editorView !== prevLoad.editorView;
|
||||
lastLoadRef.current = { bufferEpoch, editorView };
|
||||
const delay = immediate ? 0 : RENDER_DEBOUNCE_MS;
|
||||
|
||||
// The debounced body is async; wrap in a void IIFE so the timer callback
|
||||
// returns void (it handles its own errors internally — nothing awaits it).
|
||||
const timer = setTimeout(() => {
|
||||
@@ -148,10 +162,10 @@ export function LivePreview() {
|
||||
}
|
||||
}
|
||||
})();
|
||||
}, RENDER_DEBOUNCE_MS);
|
||||
}, delay);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [shownText, fitMode, uiTheme, datasets, setError]);
|
||||
}, [shownText, fitMode, uiTheme, datasets, setError, bufferEpoch, editorView]);
|
||||
|
||||
// Re-fit the chart when its container resizes (e.g. a pane drag). Vega doesn't
|
||||
// observe the element, so we do: one observer on the stable host node for the
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
|
||||
.createNew {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
margin: var(--space-4);
|
||||
height: 40px;
|
||||
padding: 0 var(--space-5);
|
||||
@@ -16,7 +20,6 @@
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
transition: background var(--dur-fast) var(--ease);
|
||||
}
|
||||
|
||||
@@ -130,17 +133,23 @@
|
||||
}
|
||||
|
||||
/* Linked-datasets indicator (spec §02): icon + count, meaning carried by the
|
||||
icon + accessible label, not colour. Pushed to the row's trailing edge. */
|
||||
icon + accessible label, not colour. Sits inline with the date as one grouped
|
||||
metadata run, separated by a middot, rather than orphaned at the row's edge. */
|
||||
.datasets {
|
||||
flex: 0 0 auto;
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.datasets::before {
|
||||
content: '·';
|
||||
margin-right: var(--space-2);
|
||||
color: var(--text-placeholder);
|
||||
}
|
||||
|
||||
.delete {
|
||||
flex: 0 0 auto;
|
||||
align-self: center;
|
||||
@@ -151,11 +160,12 @@
|
||||
background: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
padding: var(--space-1);
|
||||
padding: var(--space-2);
|
||||
border-radius: var(--radius);
|
||||
opacity: 0;
|
||||
transition: opacity var(--dur-fast) var(--ease);
|
||||
transition:
|
||||
opacity var(--dur-fast) var(--ease),
|
||||
color var(--dur-fast) var(--ease);
|
||||
}
|
||||
|
||||
.item:hover .delete,
|
||||
@@ -163,6 +173,157 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.delete:hover {
|
||||
/* Destructive intent reddens on hover AND keyboard focus, not colour-by-mouse-only
|
||||
(arch 10 — destructive controls signal danger on hover/focus). */
|
||||
.delete:hover,
|
||||
.delete:focus-visible {
|
||||
color: var(--support-error);
|
||||
}
|
||||
|
||||
/* Selected-Snippet Metadata Panel (spec §02) — pinned below the list, the active
|
||||
snippet's editable Name/Comment, read-only timestamps, linked datasets, and
|
||||
Duplicate/Delete. Capped height with its own scroll so a long comment never
|
||||
pushes the list away entirely. */
|
||||
.meta {
|
||||
flex: 0 0 auto;
|
||||
max-height: 45%;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4);
|
||||
border-top: var(--border-width) solid var(--border);
|
||||
background: var(--layer-01);
|
||||
}
|
||||
|
||||
.metaField {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.metaLabel {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.metaName,
|
||||
.metaComment {
|
||||
width: 100%;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: var(--border-width) solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--layer-02, var(--background));
|
||||
color: var(--text-primary);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.metaName {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.metaComment {
|
||||
resize: vertical;
|
||||
min-height: 2.4em;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.metaName:focus-visible,
|
||||
.metaComment:focus-visible {
|
||||
outline: 2px solid var(--focus);
|
||||
outline-offset: 1px;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* Read-only Created / Modified, as compact label→value rows. */
|
||||
.metaTimes {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.metaTimes > div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.metaTimes dt {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.metaTimes dd {
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.metaLinked {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.linkedList {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.linkedItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.linkedName {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.metaActions {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
.metaAction {
|
||||
flex: 1 1 auto;
|
||||
height: 32px;
|
||||
padding: 0 var(--space-3);
|
||||
border: var(--border-width) solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--background);
|
||||
color: var(--text-primary);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background var(--dur-fast) var(--ease),
|
||||
border-color var(--dur-fast) var(--ease),
|
||||
color var(--dur-fast) var(--ease);
|
||||
}
|
||||
|
||||
.metaAction:hover {
|
||||
background: var(--layer-02, var(--layer-01));
|
||||
border-color: var(--border-strong, var(--border));
|
||||
}
|
||||
|
||||
.metaDanger:hover {
|
||||
color: var(--support-error);
|
||||
border-color: var(--support-error);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { createSnippet } from '@core/snippet';
|
||||
import { useSnippetStore } from '../stores/SnippetStore';
|
||||
import { SnippetLibrary } from './SnippetLibrary';
|
||||
|
||||
// React 19 wants this flag set for act() to drive effects without warnings.
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
useSnippetStore.getState().reset();
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
/** Set a controlled input/textarea's value the way React expects, then fire input. */
|
||||
function typeInto(el: HTMLInputElement | HTMLTextAreaElement, value: string) {
|
||||
const proto =
|
||||
el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
|
||||
// The native value setter bypasses React 19's input value tracking so the
|
||||
// synthetic input event registers as a real change.
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
const setter = Object.getOwnPropertyDescriptor(proto, 'value')!.set!;
|
||||
setter.call(el, value);
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
|
||||
describe('SnippetLibrary metadata panel (spec §02)', () => {
|
||||
test('renders the active snippet name, comment, and linked datasets without looping', async () => {
|
||||
const s = {
|
||||
...createSnippet({ id: 'a', name: 'Bar chart', now: new Date('2026-01-01T00:00:00Z') }),
|
||||
comment: 'a note',
|
||||
datasetRefs: ['Sales'],
|
||||
};
|
||||
useSnippetStore.getState().hydrate([s], 'a');
|
||||
|
||||
// If the auto-save effect looped, this act() would throw "Maximum update depth".
|
||||
await act(async () => {
|
||||
root.render(<SnippetLibrary />);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const name = container.querySelector('input') as HTMLInputElement;
|
||||
const comment = container.querySelector('textarea') as HTMLTextAreaElement;
|
||||
expect(name.value).toBe('Bar chart');
|
||||
expect(comment.value).toBe('a note');
|
||||
expect(container.textContent).toContain('Linked datasets');
|
||||
expect(container.textContent).toContain('Sales');
|
||||
});
|
||||
|
||||
test('auto-saves an inline name edit after the debounce', () => {
|
||||
vi.useFakeTimers();
|
||||
const s = createSnippet({ id: 'a', name: 'Old', now: new Date('2026-01-01T00:00:00Z') });
|
||||
useSnippetStore.getState().hydrate([s], 'a');
|
||||
|
||||
act(() => {
|
||||
root.render(<SnippetLibrary />);
|
||||
});
|
||||
|
||||
const name = container.querySelector('input') as HTMLInputElement;
|
||||
act(() => typeInto(name, 'Renamed'));
|
||||
// Before the debounce fires, the store is unchanged.
|
||||
expect(useSnippetStore.getState().snippets[0].name).toBe('Old');
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500);
|
||||
});
|
||||
expect(useSnippetStore.getState().snippets[0].name).toBe('Renamed');
|
||||
});
|
||||
|
||||
test('Duplicate adds an independent copy and makes it active', async () => {
|
||||
const s = createSnippet({ id: 'a', name: 'Chart', now: new Date('2026-01-01T00:00:00Z') });
|
||||
useSnippetStore.getState().hydrate([s], 'a');
|
||||
|
||||
await act(async () => {
|
||||
root.render(<SnippetLibrary />);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const dup = [...container.querySelectorAll('button')].find(
|
||||
(b) => b.textContent === 'Duplicate',
|
||||
)!;
|
||||
await act(async () => {
|
||||
dup.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const { snippets, activeSnippetId } = useSnippetStore.getState();
|
||||
expect(snippets).toHaveLength(2);
|
||||
const active = snippets.find((x) => x.id === activeSnippetId)!;
|
||||
expect(active.name).toBe('Chart (copy)');
|
||||
expect(active.id).not.toBe('a');
|
||||
});
|
||||
});
|
||||
@@ -1,49 +1,32 @@
|
||||
/**
|
||||
* Snippet Library — the left pane (spec §02).
|
||||
*
|
||||
* M1 scope: the always-visible list with a pinned "Create New Snippet" item,
|
||||
* The always-visible list with a pinned "Create New Snippet" item,
|
||||
* selection/highlight, and delete. Each row carries a secondary metadata line —
|
||||
* draft status indicator, relative date, and size (spec §02). Search, sort
|
||||
* controls, the metadata panel, the dataset icon, and the storage monitor arrive
|
||||
* in later milestones.
|
||||
* draft status indicator, relative date, and size (spec §02). Below the list, the
|
||||
* Selected-Snippet Metadata Panel exposes the active snippet's editable Name and
|
||||
* Comment (auto-saved), its timestamps and linked datasets, and the Duplicate /
|
||||
* Delete operations. Search, sort controls, and the storage monitor arrive in
|
||||
* later milestones.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { formatSnippetSize, hasUnpublishedChanges, snippetSizeBytes } from '@core/snippet';
|
||||
import {
|
||||
formatSnippetSize,
|
||||
hasUnpublishedChanges,
|
||||
snippetSizeBytes,
|
||||
type Snippet,
|
||||
} from '@core/snippet';
|
||||
import { confirm } from '../stores/ConfirmStore';
|
||||
import { notify } from '../stores/NotificationStore';
|
||||
import { useSnippetStore } from '../stores/SnippetStore';
|
||||
import { selectActiveSnippet, useSnippetStore } from '../stores/SnippetStore';
|
||||
import { Icon } from './Icon';
|
||||
import styles from './SnippetLibrary.module.css';
|
||||
|
||||
/** A small database glyph for the linked-datasets indicator (icons keep their own
|
||||
* rounded geometry per the design tokens). Inherits `currentColor` so it themes. */
|
||||
function DatasetIcon() {
|
||||
return (
|
||||
<svg width="11" height="11" viewBox="0 0 16 16" aria-hidden="true" focusable="false">
|
||||
<ellipse
|
||||
cx="8"
|
||||
cy="3.5"
|
||||
rx="5.5"
|
||||
ry="2.2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.3"
|
||||
/>
|
||||
<path
|
||||
d="M2.5 3.5v9c0 1.2 2.46 2.2 5.5 2.2s5.5-1 5.5-2.2v-9"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.3"
|
||||
/>
|
||||
<path
|
||||
d="M2.5 8c0 1.2 2.46 2.2 5.5 2.2s5.5-1 5.5-2.2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.3"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
/** Auto-save settle time for the metadata panel's Name/Comment fields, mirroring
|
||||
* the editor's draft auto-save (spec §02 → "edits save automatically"). */
|
||||
const META_AUTOSAVE_MS = 400;
|
||||
|
||||
/** Compact relative date for the list (full date formatting lands in M5). */
|
||||
function relativeDate(iso: string): string {
|
||||
@@ -62,12 +45,119 @@ function relativeDate(iso: string): string {
|
||||
return then.toLocaleDateString();
|
||||
}
|
||||
|
||||
/** Absolute date-time for the metadata panel's read-only timestamps (the user's
|
||||
* date-format setting wires in at M5; until then, the locale default). */
|
||||
function formatTimestamp(iso: string): string {
|
||||
return new Date(iso).toLocaleString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Selected-Snippet Metadata Panel (spec §02). Keyed by snippet id by its caller,
|
||||
* so switching the active snippet remounts it and the local field state re-seeds
|
||||
* cleanly. Name/Comment edits auto-save: debounced while typing and flushed on
|
||||
* blur (so clicking away to another snippet commits before this unmounts). Both
|
||||
* advance the snippet's modified time via the store (§02 → Sort).
|
||||
*/
|
||||
function SnippetMeta({
|
||||
snippet,
|
||||
onDuplicate,
|
||||
onDelete,
|
||||
}: {
|
||||
snippet: Snippet;
|
||||
onDuplicate: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const renameSnippet = useSnippetStore((s) => s.renameSnippet);
|
||||
const setComment = useSnippetStore((s) => s.setComment);
|
||||
const [name, setName] = useState(snippet.name);
|
||||
const [comment, setCommentLocal] = useState(snippet.comment);
|
||||
|
||||
useEffect(() => {
|
||||
if (name === snippet.name) return;
|
||||
const t = setTimeout(() => renameSnippet(snippet.id, name), META_AUTOSAVE_MS);
|
||||
return () => clearTimeout(t);
|
||||
}, [name, snippet.id, snippet.name, renameSnippet]);
|
||||
|
||||
useEffect(() => {
|
||||
if (comment === snippet.comment) return;
|
||||
const t = setTimeout(() => setComment(snippet.id, comment), META_AUTOSAVE_MS);
|
||||
return () => clearTimeout(t);
|
||||
}, [comment, snippet.id, snippet.comment, setComment]);
|
||||
|
||||
return (
|
||||
<section className={styles.meta} aria-label="Snippet details">
|
||||
<label className={styles.metaField}>
|
||||
<span className={styles.metaLabel}>Name</span>
|
||||
<input
|
||||
className={styles.metaName}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onBlur={() => renameSnippet(snippet.id, name)}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className={styles.metaField}>
|
||||
<span className={styles.metaLabel}>Comment</span>
|
||||
<textarea
|
||||
className={styles.metaComment}
|
||||
value={comment}
|
||||
onChange={(e) => setCommentLocal(e.target.value)}
|
||||
onBlur={() => setComment(snippet.id, comment)}
|
||||
rows={2}
|
||||
placeholder="Add a note…"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<dl className={styles.metaTimes}>
|
||||
<div>
|
||||
<dt>Created</dt>
|
||||
<dd>{formatTimestamp(snippet.created)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Modified</dt>
|
||||
<dd>{formatTimestamp(snippet.modified)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{snippet.datasetRefs.length > 0 && (
|
||||
<div className={styles.metaLinked}>
|
||||
<span className={styles.metaLabel}>Linked datasets</span>
|
||||
<ul className={styles.linkedList}>
|
||||
{snippet.datasetRefs.map((ref) => (
|
||||
<li key={ref} className={styles.linkedItem}>
|
||||
<Icon name="dataset" />
|
||||
<span className={styles.linkedName}>{ref}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.metaActions}>
|
||||
<button type="button" className={styles.metaAction} onClick={onDuplicate}>
|
||||
Duplicate
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.metaAction} ${styles.metaDanger}`}
|
||||
onClick={onDelete}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function SnippetLibrary() {
|
||||
const snippets = useSnippetStore(useShallow((s) => s.snippets));
|
||||
const activeId = useSnippetStore((s) => s.activeSnippetId);
|
||||
const activeSnippet = useSnippetStore(selectActiveSnippet);
|
||||
const createSnippet = useSnippetStore((s) => s.createSnippet);
|
||||
const selectSnippet = useSnippetStore((s) => s.selectSnippet);
|
||||
const removeSnippet = useSnippetStore((s) => s.removeSnippet);
|
||||
const duplicateActiveSnippet = useSnippetStore((s) => s.duplicateActiveSnippet);
|
||||
|
||||
// Default ordering: newest-modified first (spec §02 → Sort).
|
||||
const ordered = [...snippets].sort((a, b) => b.modified.localeCompare(a.modified));
|
||||
@@ -91,13 +181,26 @@ export function SnippetLibrary() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleDuplicate = () => {
|
||||
const id = duplicateActiveSnippet();
|
||||
if (!id) return;
|
||||
// The copy isn't visibly distinct from its source at a glance, so the outcome
|
||||
// needs a toast (spec §02 → Duplicate; contract 10 §1 — toast what isn't
|
||||
// already self-evident, unlike Create which opens visibly in the editor).
|
||||
notify({
|
||||
kind: 'success',
|
||||
title: 'Snippet duplicated',
|
||||
message: 'An independent copy was added and is now active.',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.library}>
|
||||
{/* Create raises no toast: the new snippet opens in the editor, so the
|
||||
result is already on-screen (spec §02; docs/architecture/10 → Toast
|
||||
copy). Delete/duplicate toast because the outcome isn't visible. */}
|
||||
<button className={styles.createNew} onClick={() => createSnippet()}>
|
||||
+ Create New Snippet
|
||||
<Icon name="add" /> Create New Snippet
|
||||
</button>
|
||||
|
||||
<ul className={styles.list}>
|
||||
@@ -146,7 +249,7 @@ export function SnippetLibrary() {
|
||||
title={`Linked datasets: ${s.datasetRefs.join(', ')}`}
|
||||
aria-label={`${s.datasetRefs.length} linked dataset${s.datasetRefs.length === 1 ? '' : 's'}`}
|
||||
>
|
||||
<DatasetIcon />
|
||||
<Icon name="dataset" />
|
||||
{s.datasetRefs.length}
|
||||
</span>
|
||||
)}
|
||||
@@ -158,12 +261,23 @@ export function SnippetLibrary() {
|
||||
title="Delete snippet"
|
||||
onClick={() => void handleDelete(s.id, s.name)}
|
||||
>
|
||||
✕
|
||||
<Icon name="delete" />
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
{/* Selected-Snippet Metadata Panel (spec §02). Keyed by id so it re-seeds
|
||||
its local field state when the active snippet changes. */}
|
||||
{activeSnippet && (
|
||||
<SnippetMeta
|
||||
key={activeSnippet.id}
|
||||
snippet={activeSnippet}
|
||||
onDuplicate={handleDuplicate}
|
||||
onDelete={() => void handleDelete(activeSnippet.id, activeSnippet.name)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user