@notis_ai/cli 0.2.0-beta.76.1 → 0.2.0-beta.95.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/README.md +20 -0
- package/dist/scaffolds/notis-database/metadata/screenshot-1.png +0 -0
- package/dist/scaffolds/notis-notes/app/page.tsx +313 -175
- package/dist/scaffolds/notis-notes/metadata/screenshot-1.png +0 -0
- package/dist/scaffolds/notis-random/metadata/screenshot-1.png +0 -0
- package/dist/scaffolds/notis-random/metadata/screenshot-2.png +0 -0
- package/package.json +2 -1
- package/skills/notis-apps/SKILL.md +25 -5
- package/skills/notis-apps/cli.md +20 -0
- package/src/cli.js +20 -1
- package/src/command-specs/apps.js +249 -7
- package/src/runtime/agent-browser.js +89 -0
- package/src/runtime/app-boundary-validator.js +41 -10
- package/src/runtime/app-dev-server.js +92 -2
- package/src/runtime/app-dev-sessions.js +77 -11
- package/src/runtime/app-platform.js +5 -23
- package/dist/scaffolds/notis-database/metadata/cover.png +0 -0
- package/dist/scaffolds/notis-database/metadata/screenshot-2.png +0 -0
- package/dist/scaffolds/notis-database/metadata/screenshot-3.png +0 -0
- package/dist/scaffolds/notis-notes/metadata/cover.png +0 -0
- package/dist/scaffolds/notis-notes/metadata/screenshot-2.png +0 -0
- package/dist/scaffolds/notis-notes/metadata/screenshot-3.png +0 -0
- package/dist/scaffolds/notis-random/metadata/cover.png +0 -0
- package/dist/scaffolds/notis-random/metadata/screenshot-3.png +0 -0
- package/template/metadata/cover.png +0 -0
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
useTool,
|
|
14
14
|
type MultiSelectController,
|
|
15
15
|
} from '@notis/sdk';
|
|
16
|
-
import {
|
|
16
|
+
import { ArrowUpRightIcon as ArrowUpRight, CubeIcon as Boxes, BookOpenIcon as BookOpen, BookOpenTextIcon as BookOpenText, CalendarIcon as CalendarDays, CaretDownIcon as ChevronDown, CaretLeftIcon as ChevronLeft, CaretRightIcon as ChevronRight, FileTextIcon as FileText, FolderIcon as Folder, FolderMinusIcon as FolderMinus, FolderOpenIcon as FolderOpen, FolderPlusIcon as FolderPlus, FoldersIcon as Folders, SquaresFourIcon as LayoutGrid, CircleNotchIcon as Loader2, MagnifyingGlassIcon as Search, NotePencilIcon as NotebookPen, PencilIcon as Pencil, PlusIcon as Plus, NoteIcon as StickyNote, TableIcon as Table2, TrashIcon as Trash, XIcon as X, type Icon } from '@phosphor-icons/react';
|
|
17
17
|
import * as PhosphorIcons from '@phosphor-icons/react';
|
|
18
18
|
|
|
19
19
|
import { Button } from '@/components/ui/button';
|
|
@@ -144,6 +144,8 @@ type UpsertDocumentArgs = {
|
|
|
144
144
|
title?: string;
|
|
145
145
|
properties?: Record<string, unknown>;
|
|
146
146
|
contentBlocknote?: Array<Record<string, unknown>> | null;
|
|
147
|
+
/** 'archive' soft-deletes the document (sets archived_at); 'restore' undoes it. */
|
|
148
|
+
operation?: 'create' | 'update' | 'archive' | 'restore';
|
|
147
149
|
};
|
|
148
150
|
|
|
149
151
|
const NOTE_DATABASE_SLUG = 'notes';
|
|
@@ -151,6 +153,16 @@ const FOLDER_DATABASE_SLUG = 'note_folders';
|
|
|
151
153
|
const DEFAULT_NOTE_TITLE = 'Untitled note';
|
|
152
154
|
const WEEKDAY_LABELS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
|
153
155
|
|
|
156
|
+
// Sentinel for the built-in "Created" date, sourced from each document's
|
|
157
|
+
// created_at timestamp rather than a user-defined date property.
|
|
158
|
+
const CREATED_DATE_FIELD = '__created__';
|
|
159
|
+
const CREATED_DATE_LABEL = 'Created';
|
|
160
|
+
|
|
161
|
+
type CalendarDateField = {
|
|
162
|
+
value: string;
|
|
163
|
+
label: string;
|
|
164
|
+
};
|
|
165
|
+
|
|
154
166
|
const TABS: TabConfig[] = [
|
|
155
167
|
{ key: 'gallery', label: 'Gallery', icon: LayoutGrid },
|
|
156
168
|
{ key: 'table', label: 'Table', icon: Table2 },
|
|
@@ -452,6 +464,7 @@ function buildUpsertNoteArgs(args: UpsertDocumentArgs): UpsertNoteArgs {
|
|
|
452
464
|
if (typeof args.title === 'string') payload.title = args.title;
|
|
453
465
|
if (args.properties) Object.assign(payload, args.properties);
|
|
454
466
|
if ('contentBlocknote' in args) payload.content_blocknote = args.contentBlocknote ?? null;
|
|
467
|
+
if (args.operation) payload.operation = args.operation;
|
|
455
468
|
return payload;
|
|
456
469
|
}
|
|
457
470
|
|
|
@@ -469,6 +482,11 @@ function getDateInputValue(value: unknown): string {
|
|
|
469
482
|
return getDateKey(value) ?? '';
|
|
470
483
|
}
|
|
471
484
|
|
|
485
|
+
function getNoteDateValue(note: DocumentRecord, field: string): unknown {
|
|
486
|
+
if (field === CREATED_DATE_FIELD) return note.createdAt;
|
|
487
|
+
return note.properties[field];
|
|
488
|
+
}
|
|
489
|
+
|
|
472
490
|
function getRelationIds(value: unknown): string[] {
|
|
473
491
|
if (!Array.isArray(value)) return [];
|
|
474
492
|
return value.filter((item): item is string => isPresentString(item));
|
|
@@ -784,7 +802,7 @@ function FolderIconPicker({
|
|
|
784
802
|
useEffect(() => {
|
|
785
803
|
if (!open) return;
|
|
786
804
|
|
|
787
|
-
function handlePointerDown(event:
|
|
805
|
+
function handlePointerDown(event: Event) {
|
|
788
806
|
const target = event.target;
|
|
789
807
|
if (target instanceof Node && containerRef.current?.contains(target)) {
|
|
790
808
|
return;
|
|
@@ -883,26 +901,6 @@ function FolderIconPicker({
|
|
|
883
901
|
);
|
|
884
902
|
}
|
|
885
903
|
|
|
886
|
-
function ToolbarIconButton({
|
|
887
|
-
icon: Icon,
|
|
888
|
-
label,
|
|
889
|
-
}: {
|
|
890
|
-
icon: Icon;
|
|
891
|
-
label: string;
|
|
892
|
-
}) {
|
|
893
|
-
return (
|
|
894
|
-
<button
|
|
895
|
-
type="button"
|
|
896
|
-
aria-label={label}
|
|
897
|
-
aria-disabled="true"
|
|
898
|
-
tabIndex={-1}
|
|
899
|
-
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted/40 hover:text-foreground"
|
|
900
|
-
>
|
|
901
|
-
<Icon className="h-3.5 w-3.5" />
|
|
902
|
-
</button>
|
|
903
|
-
);
|
|
904
|
-
}
|
|
905
|
-
|
|
906
904
|
function ViewPill({
|
|
907
905
|
config,
|
|
908
906
|
active,
|
|
@@ -948,6 +946,8 @@ function PageHeader({
|
|
|
948
946
|
onTitleDraftChange,
|
|
949
947
|
onSubmitTitleEdit,
|
|
950
948
|
onCancelTitleEdit,
|
|
949
|
+
onCreateNote,
|
|
950
|
+
creatingNote = false,
|
|
951
951
|
}: {
|
|
952
952
|
title: string;
|
|
953
953
|
icon: string | null;
|
|
@@ -966,6 +966,8 @@ function PageHeader({
|
|
|
966
966
|
onTitleDraftChange?: (title: string) => void;
|
|
967
967
|
onSubmitTitleEdit?: () => void;
|
|
968
968
|
onCancelTitleEdit?: () => void;
|
|
969
|
+
onCreateNote?: () => void;
|
|
970
|
+
creatingNote?: boolean;
|
|
969
971
|
}) {
|
|
970
972
|
return (
|
|
971
973
|
<div className="flex flex-col gap-3 px-6 pt-6">
|
|
@@ -1036,20 +1038,14 @@ function PageHeader({
|
|
|
1036
1038
|
))}
|
|
1037
1039
|
</div>
|
|
1038
1040
|
<div className="ml-auto flex items-center gap-0.5">
|
|
1039
|
-
<ToolbarIconButton icon={SlidersHorizontal} label="Filter" />
|
|
1040
|
-
<ToolbarIconButton icon={ArrowUpDown} label="Sort" />
|
|
1041
|
-
<ToolbarIconButton icon={Link2} label="Copy link" />
|
|
1042
|
-
<ToolbarIconButton icon={Search} label="Search" />
|
|
1043
|
-
<ToolbarIconButton icon={Eye} label="Views" />
|
|
1044
|
-
<ToolbarIconButton icon={Maximize2} label="Expand" />
|
|
1045
1041
|
<button
|
|
1046
1042
|
type="button"
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
className="ml-1 inline-flex h-7 items-center gap-1 rounded-md bg-primary px-2.5 text-[12.5px] font-semibold text-primary-foreground transition-colors hover:bg-primary/90"
|
|
1043
|
+
onClick={onCreateNote}
|
|
1044
|
+
disabled={!onCreateNote || creatingNote}
|
|
1045
|
+
className="ml-1 inline-flex h-7 items-center gap-1 rounded-md bg-primary px-2.5 text-[12.5px] font-semibold text-primary-foreground transition-colors hover:bg-primary/90 disabled:pointer-events-none disabled:opacity-60"
|
|
1051
1046
|
>
|
|
1052
|
-
|
|
1047
|
+
{creatingNote ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Plus className="h-3.5 w-3.5" />}
|
|
1048
|
+
{creatingNote ? 'Creating…' : 'New'}
|
|
1053
1049
|
</button>
|
|
1054
1050
|
</div>
|
|
1055
1051
|
</div>
|
|
@@ -1058,13 +1054,13 @@ function PageHeader({
|
|
|
1058
1054
|
}
|
|
1059
1055
|
|
|
1060
1056
|
export default function NotesPage() {
|
|
1061
|
-
const { app, route,
|
|
1057
|
+
const { app, route, collectionItem: rawCollectionItem } = useNotis();
|
|
1062
1058
|
const navigation = useNotisNavigation();
|
|
1063
1059
|
const { request } = useBackend();
|
|
1064
1060
|
const upsertNote = useTool<UpsertNoteArgs, UpsertNoteResult>('LOCAL_NOTIS_DATABASE_UPSERT_NOTES');
|
|
1065
1061
|
|
|
1066
1062
|
const [activeTab, setActiveTab] = useState<TabKey>('gallery');
|
|
1067
|
-
const [activeDateProperty, setActiveDateProperty] = useState(
|
|
1063
|
+
const [activeDateProperty, setActiveDateProperty] = useState(CREATED_DATE_FIELD);
|
|
1068
1064
|
const [visibleMonth, setVisibleMonth] = useState(() => new Date());
|
|
1069
1065
|
const [creatingDocument, setCreatingDocument] = useState(false);
|
|
1070
1066
|
const [savingNoteId, setSavingNoteId] = useState<string | null>(null);
|
|
@@ -1076,9 +1072,12 @@ export default function NotesPage() {
|
|
|
1076
1072
|
const [editingFolderTitle, setEditingFolderTitle] = useState(false);
|
|
1077
1073
|
const [folderTitleOverride, setFolderTitleOverride] = useState<{ id: string; title: string } | null>(null);
|
|
1078
1074
|
const [folderIconOverride, setFolderIconOverride] = useState<{ id: string; icon: string | null } | null>(null);
|
|
1075
|
+
// Which bulk folder action is awaiting a target-folder pick, if any.
|
|
1076
|
+
const [pendingFolderAction, setPendingFolderAction] = useState<'move' | 'add' | null>(null);
|
|
1077
|
+
const [bulkRunning, setBulkRunning] = useState(false);
|
|
1079
1078
|
const folderRenameSubmittingRef = useRef(false);
|
|
1080
1079
|
|
|
1081
|
-
const collectionItem = normalizeCollectionItem(
|
|
1080
|
+
const collectionItem = normalizeCollectionItem(rawCollectionItem);
|
|
1082
1081
|
const noteSchema = useNoteSchema(NOTE_DATABASE_SLUG);
|
|
1083
1082
|
const noteProperties = noteSchema.properties;
|
|
1084
1083
|
const titleProperty = noteProperties.find((p) => p.type === 'title');
|
|
@@ -1140,19 +1139,26 @@ export default function NotesPage() {
|
|
|
1140
1139
|
const folderOptions = buildFolderOptions(folders);
|
|
1141
1140
|
const folderNameById = new Map(folderOptions.map((f) => [f.id, f.title]));
|
|
1142
1141
|
|
|
1143
|
-
|
|
1144
|
-
|
|
1142
|
+
// The calendar always offers a built-in "Created" field (sourced from each
|
|
1143
|
+
// note's created_at) plus any user-defined date properties on the database.
|
|
1144
|
+
const calendarDateFields: CalendarDateField[] = [
|
|
1145
|
+
{ value: CREATED_DATE_FIELD, label: CREATED_DATE_LABEL },
|
|
1146
|
+
...dateProperties.map((p) => ({ value: p.name, label: p.name })),
|
|
1147
|
+
];
|
|
1148
|
+
const activeCalendarField = calendarDateFields.some((f) => f.value === activeDateProperty)
|
|
1149
|
+
? activeDateProperty
|
|
1150
|
+
: CREATED_DATE_FIELD;
|
|
1145
1151
|
|
|
1146
1152
|
const notesByDay = new Map<string, DocumentRecord[]>();
|
|
1147
1153
|
for (const note of notes) {
|
|
1148
|
-
const key = getDateKey(note
|
|
1154
|
+
const key = getDateKey(getNoteDateValue(note, activeCalendarField));
|
|
1149
1155
|
if (!key) continue;
|
|
1150
1156
|
const bucket = notesByDay.get(key) ?? [];
|
|
1151
1157
|
bucket.push(note);
|
|
1152
1158
|
notesByDay.set(key, bucket);
|
|
1153
1159
|
}
|
|
1154
1160
|
const scheduledNotesCount = notes.filter((n) =>
|
|
1155
|
-
|
|
1161
|
+
Boolean(getDateKey(getNoteDateValue(n, activeCalendarField))),
|
|
1156
1162
|
).length;
|
|
1157
1163
|
|
|
1158
1164
|
const monthDays = buildCalendarDays(visibleMonth);
|
|
@@ -1170,13 +1176,10 @@ export default function NotesPage() {
|
|
|
1170
1176
|
}, [activeFolderId]);
|
|
1171
1177
|
|
|
1172
1178
|
useEffect(() => {
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
if (!dateProperties.some((p) => p.name === activeDateProperty)) {
|
|
1178
|
-
setActiveDateProperty(dateProperties[0].name);
|
|
1179
|
-
}
|
|
1179
|
+
const valid =
|
|
1180
|
+
activeDateProperty === CREATED_DATE_FIELD ||
|
|
1181
|
+
dateProperties.some((p) => p.name === activeDateProperty);
|
|
1182
|
+
if (!valid) setActiveDateProperty(CREATED_DATE_FIELD);
|
|
1180
1183
|
}, [activeDateProperty, dateProperties]);
|
|
1181
1184
|
|
|
1182
1185
|
async function openNote(document: DocumentRecord) {
|
|
@@ -1323,38 +1326,96 @@ export default function NotesPage() {
|
|
|
1323
1326
|
}
|
|
1324
1327
|
}
|
|
1325
1328
|
|
|
1326
|
-
|
|
1329
|
+
// Holds both table rows and gallery cards so Shift+Arrow can scroll the head
|
|
1330
|
+
// into view regardless of the active view.
|
|
1331
|
+
const rowRefs = useRef(new Map<string, HTMLElement>());
|
|
1332
|
+
const selectionEnabled = activeTab !== 'calendar';
|
|
1327
1333
|
const multiSelect = useMultiSelect<DocumentRecord>({
|
|
1328
1334
|
items: notes,
|
|
1329
1335
|
getId: (note) => note.id,
|
|
1330
|
-
bindKeyboardShortcuts:
|
|
1331
|
-
enableDragSelect:
|
|
1336
|
+
bindKeyboardShortcuts: selectionEnabled,
|
|
1337
|
+
enableDragSelect: selectionEnabled,
|
|
1332
1338
|
onHeadChange: (id) => {
|
|
1333
1339
|
if (!id) return;
|
|
1334
1340
|
rowRefs.current.get(id)?.scrollIntoView({ block: 'nearest' });
|
|
1335
1341
|
},
|
|
1336
1342
|
});
|
|
1337
1343
|
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1344
|
+
// Clear the selection when the visible set changes out from under it (folder
|
|
1345
|
+
// switch) or when entering a view that can't act on a selection (calendar),
|
|
1346
|
+
// so bulk actions never apply to off-screen notes.
|
|
1347
|
+
const clearSelection = multiSelect.clear;
|
|
1348
|
+
useEffect(() => {
|
|
1349
|
+
clearSelection();
|
|
1350
|
+
}, [activeFolderId, clearSelection]);
|
|
1351
|
+
useEffect(() => {
|
|
1352
|
+
if (!selectionEnabled) clearSelection();
|
|
1353
|
+
}, [selectionEnabled, clearSelection]);
|
|
1354
|
+
|
|
1355
|
+
// Runs `apply` for each selected note in parallel, then clears + refetches.
|
|
1356
|
+
// `apply` receives the full note so handlers can read its current properties.
|
|
1357
|
+
async function runBulk(
|
|
1358
|
+
apply: (note: DocumentRecord) => UpsertDocumentArgs,
|
|
1359
|
+
failureMessage: string,
|
|
1360
|
+
) {
|
|
1361
|
+
const selected = multiSelect.getSelectedItems();
|
|
1362
|
+
if (selected.length === 0) return;
|
|
1363
|
+
setBulkRunning(true);
|
|
1341
1364
|
setErrorMessage(null);
|
|
1342
1365
|
try {
|
|
1343
|
-
await Promise.all(
|
|
1344
|
-
ids.map((documentId) =>
|
|
1345
|
-
upsertNoteDocument({
|
|
1346
|
-
documentId,
|
|
1347
|
-
properties: { [folderPropertyName]: [] },
|
|
1348
|
-
}),
|
|
1349
|
-
),
|
|
1350
|
-
);
|
|
1366
|
+
await Promise.all(selected.map((note) => upsertNoteDocument(apply(note))));
|
|
1351
1367
|
multiSelect.clear();
|
|
1352
1368
|
notesQuery.refetch();
|
|
1353
1369
|
} catch (error) {
|
|
1354
|
-
setErrorMessage(error instanceof Error ? error.message :
|
|
1370
|
+
setErrorMessage(error instanceof Error ? error.message : failureMessage);
|
|
1371
|
+
} finally {
|
|
1372
|
+
setBulkRunning(false);
|
|
1355
1373
|
}
|
|
1356
1374
|
}
|
|
1357
1375
|
|
|
1376
|
+
function bulkClearFolder() {
|
|
1377
|
+
return runBulk(
|
|
1378
|
+
(note) => ({ documentId: note.id, properties: { [folderPropertyName]: [] } }),
|
|
1379
|
+
'Failed to update notes',
|
|
1380
|
+
);
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
function bulkDelete() {
|
|
1384
|
+
return runBulk(
|
|
1385
|
+
(note) => ({ documentId: note.id, operation: 'archive' }),
|
|
1386
|
+
'Failed to delete notes',
|
|
1387
|
+
);
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
function bulkMoveToFolder(folderId: string) {
|
|
1391
|
+
return runBulk(
|
|
1392
|
+
(note) => ({ documentId: note.id, properties: { [folderPropertyName]: [folderId] } }),
|
|
1393
|
+
'Failed to move notes',
|
|
1394
|
+
);
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
function bulkAddToFolder(folderId: string) {
|
|
1398
|
+
return runBulk(
|
|
1399
|
+
(note) => ({
|
|
1400
|
+
documentId: note.id,
|
|
1401
|
+
properties: {
|
|
1402
|
+
[folderPropertyName]: Array.from(
|
|
1403
|
+
new Set([...getRelationIds(note.properties[folderPropertyName]), folderId]),
|
|
1404
|
+
),
|
|
1405
|
+
},
|
|
1406
|
+
}),
|
|
1407
|
+
'Failed to add notes to folder',
|
|
1408
|
+
);
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
async function handleFolderPick(folderId: string) {
|
|
1412
|
+
const action = pendingFolderAction;
|
|
1413
|
+
setPendingFolderAction(null);
|
|
1414
|
+
if (!action) return;
|
|
1415
|
+
if (action === 'move') await bulkMoveToFolder(folderId);
|
|
1416
|
+
else await bulkAddToFolder(folderId);
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1358
1419
|
return (
|
|
1359
1420
|
<main className="flex min-h-screen flex-col bg-background">
|
|
1360
1421
|
<PageHeader
|
|
@@ -1375,6 +1436,8 @@ export default function NotesPage() {
|
|
|
1375
1436
|
onCancelTitleEdit={cancelFolderTitleEdit}
|
|
1376
1437
|
activeTab={activeTab}
|
|
1377
1438
|
onTabChange={setActiveTab}
|
|
1439
|
+
onCreateNote={() => void createDocument()}
|
|
1440
|
+
creatingNote={creatingDocument}
|
|
1378
1441
|
/>
|
|
1379
1442
|
|
|
1380
1443
|
{topLevelError ? (
|
|
@@ -1394,6 +1457,8 @@ export default function NotesPage() {
|
|
|
1394
1457
|
creatingDocument={creatingDocument}
|
|
1395
1458
|
currentFolderLabel={currentFolderLabel}
|
|
1396
1459
|
hasCollectionItem={Boolean(activeFolderId)}
|
|
1460
|
+
multiSelect={multiSelect}
|
|
1461
|
+
rowRefs={rowRefs.current}
|
|
1397
1462
|
/>
|
|
1398
1463
|
) : null}
|
|
1399
1464
|
|
|
@@ -1417,35 +1482,63 @@ export default function NotesPage() {
|
|
|
1417
1482
|
|
|
1418
1483
|
{!isLoading && activeTab === 'calendar' ? (
|
|
1419
1484
|
<CalendarBody
|
|
1420
|
-
notes={notes}
|
|
1421
1485
|
monthDays={monthDays}
|
|
1422
1486
|
visibleMonth={visibleMonth}
|
|
1423
1487
|
setVisibleMonth={setVisibleMonth}
|
|
1424
1488
|
notesByDay={notesByDay}
|
|
1425
1489
|
statusPropertyName={statusPropertyName}
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
activeDateProperty={activeDateProperty}
|
|
1490
|
+
calendarDateFields={calendarDateFields}
|
|
1491
|
+
activeDateProperty={activeCalendarField}
|
|
1429
1492
|
setActiveDateProperty={setActiveDateProperty}
|
|
1430
1493
|
scheduledNotesCount={scheduledNotesCount}
|
|
1431
1494
|
onOpen={openNote}
|
|
1432
1495
|
/>
|
|
1433
1496
|
) : null}
|
|
1434
1497
|
|
|
1435
|
-
{
|
|
1498
|
+
{selectionEnabled ? (
|
|
1436
1499
|
<>
|
|
1437
1500
|
<MultiSelectDragOverlay rect={multiSelect.dragRect} />
|
|
1501
|
+
{pendingFolderAction ? (
|
|
1502
|
+
<BulkFolderPicker
|
|
1503
|
+
mode={pendingFolderAction}
|
|
1504
|
+
folderOptions={folderOptions}
|
|
1505
|
+
busy={bulkRunning}
|
|
1506
|
+
onPick={(folderId) => void handleFolderPick(folderId)}
|
|
1507
|
+
onClose={() => setPendingFolderAction(null)}
|
|
1508
|
+
/>
|
|
1509
|
+
) : null}
|
|
1438
1510
|
<MultiSelectActionBar
|
|
1439
1511
|
selectedCount={multiSelect.selectedCount}
|
|
1440
1512
|
itemLabel={{ singular: 'note', plural: 'notes' }}
|
|
1441
1513
|
actions={[
|
|
1514
|
+
{
|
|
1515
|
+
id: 'move-folder',
|
|
1516
|
+
label: 'Move to folder',
|
|
1517
|
+
shortcut: 'M',
|
|
1518
|
+
icon: <FolderOpen className="h-3.5 w-3.5" />,
|
|
1519
|
+
onRun: () => setPendingFolderAction('move'),
|
|
1520
|
+
},
|
|
1521
|
+
{
|
|
1522
|
+
id: 'add-folder',
|
|
1523
|
+
label: 'Add to folder',
|
|
1524
|
+
shortcut: 'F',
|
|
1525
|
+
icon: <FolderPlus className="h-3.5 w-3.5" />,
|
|
1526
|
+
onRun: () => setPendingFolderAction('add'),
|
|
1527
|
+
},
|
|
1442
1528
|
{
|
|
1443
1529
|
id: 'clear-folder',
|
|
1444
1530
|
label: 'Move out of folder',
|
|
1445
|
-
shortcut: 'M',
|
|
1446
1531
|
icon: <FolderMinus className="h-3.5 w-3.5" />,
|
|
1447
1532
|
onRun: bulkClearFolder,
|
|
1448
1533
|
},
|
|
1534
|
+
{
|
|
1535
|
+
id: 'delete',
|
|
1536
|
+
label: 'Delete',
|
|
1537
|
+
shortcut: '#',
|
|
1538
|
+
destructive: true,
|
|
1539
|
+
icon: <Trash className="h-3.5 w-3.5" />,
|
|
1540
|
+
onRun: bulkDelete,
|
|
1541
|
+
},
|
|
1449
1542
|
]}
|
|
1450
1543
|
/>
|
|
1451
1544
|
</>
|
|
@@ -1466,6 +1559,8 @@ function GalleryBody({
|
|
|
1466
1559
|
creatingDocument,
|
|
1467
1560
|
currentFolderLabel,
|
|
1468
1561
|
hasCollectionItem,
|
|
1562
|
+
multiSelect,
|
|
1563
|
+
rowRefs,
|
|
1469
1564
|
}: {
|
|
1470
1565
|
notes: DocumentRecord[];
|
|
1471
1566
|
statusPropertyName: string | null;
|
|
@@ -1474,6 +1569,8 @@ function GalleryBody({
|
|
|
1474
1569
|
creatingDocument: boolean;
|
|
1475
1570
|
currentFolderLabel: string;
|
|
1476
1571
|
hasCollectionItem: boolean;
|
|
1572
|
+
multiSelect: MultiSelectController<DocumentRecord>;
|
|
1573
|
+
rowRefs: Map<string, HTMLElement>;
|
|
1477
1574
|
}) {
|
|
1478
1575
|
if (!notes.length) {
|
|
1479
1576
|
return (
|
|
@@ -1501,13 +1598,26 @@ function GalleryBody({
|
|
|
1501
1598
|
}
|
|
1502
1599
|
|
|
1503
1600
|
return (
|
|
1504
|
-
<div
|
|
1601
|
+
<div
|
|
1602
|
+
{...multiSelect.getContainerProps()}
|
|
1603
|
+
className="grid grid-cols-1 gap-4 px-4 py-4 sm:grid-cols-2 sm:px-6 lg:grid-cols-3 xl:grid-cols-4"
|
|
1604
|
+
>
|
|
1505
1605
|
{notes.map((note) => (
|
|
1506
1606
|
<NoteCard
|
|
1507
1607
|
key={note.id}
|
|
1508
1608
|
note={note}
|
|
1509
1609
|
statusPropertyName={statusPropertyName}
|
|
1510
1610
|
onOpen={() => onOpenNote(note)}
|
|
1611
|
+
isSelected={multiSelect.isSelected(note.id)}
|
|
1612
|
+
itemProps={multiSelect.getItemProps(note.id)}
|
|
1613
|
+
checkboxProps={multiSelect.getCheckboxProps(note.id)}
|
|
1614
|
+
cardRef={(node) => {
|
|
1615
|
+
if (node) {
|
|
1616
|
+
rowRefs.set(note.id, node);
|
|
1617
|
+
} else {
|
|
1618
|
+
rowRefs.delete(note.id);
|
|
1619
|
+
}
|
|
1620
|
+
}}
|
|
1511
1621
|
/>
|
|
1512
1622
|
))}
|
|
1513
1623
|
</div>
|
|
@@ -1518,25 +1628,56 @@ function NoteCard({
|
|
|
1518
1628
|
note,
|
|
1519
1629
|
statusPropertyName,
|
|
1520
1630
|
onOpen,
|
|
1631
|
+
isSelected,
|
|
1632
|
+
itemProps,
|
|
1633
|
+
checkboxProps,
|
|
1634
|
+
cardRef,
|
|
1521
1635
|
}: {
|
|
1522
1636
|
note: DocumentRecord;
|
|
1523
1637
|
statusPropertyName: string | null;
|
|
1524
1638
|
onOpen: () => void;
|
|
1639
|
+
isSelected: boolean;
|
|
1640
|
+
itemProps: { 'data-notis-row-id': string; onMouseDown: (event: React.MouseEvent) => void };
|
|
1641
|
+
checkboxProps: { isSelected: boolean; onClick: (event: React.MouseEvent) => void };
|
|
1642
|
+
cardRef: (node: HTMLDivElement | null) => void;
|
|
1525
1643
|
}) {
|
|
1526
1644
|
const status = statusPropertyName ? getStatusLabel(note.properties[statusPropertyName]) : null;
|
|
1527
1645
|
const coverUrl = getCoverUrl(note);
|
|
1528
1646
|
const title = getNoteTitle(note);
|
|
1529
1647
|
const previewText = getNotePreviewText(note);
|
|
1530
1648
|
|
|
1649
|
+
// A plain div (no `role="button"`) so the SDK's drag-select can arm from a
|
|
1650
|
+
// card body; keyboard access is preserved via tabIndex + Enter.
|
|
1531
1651
|
return (
|
|
1532
|
-
<
|
|
1533
|
-
|
|
1652
|
+
<div
|
|
1653
|
+
{...itemProps}
|
|
1654
|
+
ref={cardRef}
|
|
1655
|
+
tabIndex={0}
|
|
1534
1656
|
onClick={onOpen}
|
|
1657
|
+
onKeyDown={(e) => {
|
|
1658
|
+
if (e.key === 'Enter') {
|
|
1659
|
+
e.preventDefault();
|
|
1660
|
+
onOpen();
|
|
1661
|
+
}
|
|
1662
|
+
}}
|
|
1535
1663
|
className={cn(
|
|
1536
|
-
'group flex h-full w-full flex-col overflow-hidden rounded-xl border
|
|
1664
|
+
'group relative flex h-full w-full cursor-pointer flex-col overflow-hidden rounded-xl border bg-card text-left',
|
|
1537
1665
|
'transition-colors hover:border-foreground/20 hover:shadow-sm',
|
|
1666
|
+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
|
1667
|
+
isSelected ? 'border-primary ring-2 ring-primary' : 'border-border',
|
|
1538
1668
|
)}
|
|
1539
1669
|
>
|
|
1670
|
+
<div className="absolute left-2 top-2 z-10">
|
|
1671
|
+
<MultiSelectCheckbox
|
|
1672
|
+
{...checkboxProps}
|
|
1673
|
+
alwaysVisible={isSelected}
|
|
1674
|
+
ariaLabel={isSelected ? 'Deselect note' : 'Select note'}
|
|
1675
|
+
className={cn(
|
|
1676
|
+
'transition-opacity',
|
|
1677
|
+
!isSelected && 'opacity-0 group-hover:opacity-100 focus-visible:opacity-100',
|
|
1678
|
+
)}
|
|
1679
|
+
/>
|
|
1680
|
+
</div>
|
|
1540
1681
|
<div className="relative aspect-[16/10] w-full overflow-hidden">
|
|
1541
1682
|
{coverUrl ? (
|
|
1542
1683
|
// eslint-disable-next-line @next/next/no-img-element
|
|
@@ -1560,7 +1701,96 @@ function NoteCard({
|
|
|
1560
1701
|
{title}
|
|
1561
1702
|
</span>
|
|
1562
1703
|
</div>
|
|
1563
|
-
</
|
|
1704
|
+
</div>
|
|
1705
|
+
);
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
/* -------------------------------------------------------------------------- */
|
|
1709
|
+
/* Bulk folder picker (Move to / Add to folder) */
|
|
1710
|
+
/* -------------------------------------------------------------------------- */
|
|
1711
|
+
|
|
1712
|
+
function BulkFolderPicker({
|
|
1713
|
+
mode,
|
|
1714
|
+
folderOptions,
|
|
1715
|
+
busy,
|
|
1716
|
+
onPick,
|
|
1717
|
+
onClose,
|
|
1718
|
+
}: {
|
|
1719
|
+
mode: 'move' | 'add';
|
|
1720
|
+
folderOptions: FolderOption[];
|
|
1721
|
+
busy: boolean;
|
|
1722
|
+
onPick: (folderId: string) => void;
|
|
1723
|
+
onClose: () => void;
|
|
1724
|
+
}) {
|
|
1725
|
+
const [query, setQuery] = useState('');
|
|
1726
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
1727
|
+
const normalizedQuery = query.trim().toLowerCase();
|
|
1728
|
+
const filtered = normalizedQuery
|
|
1729
|
+
? folderOptions.filter((f) => f.pathLabel.toLowerCase().includes(normalizedQuery))
|
|
1730
|
+
: folderOptions;
|
|
1731
|
+
|
|
1732
|
+
useEffect(() => {
|
|
1733
|
+
function handlePointerDown(event: Event) {
|
|
1734
|
+
const target = event.target;
|
|
1735
|
+
if (target instanceof Node && containerRef.current?.contains(target)) return;
|
|
1736
|
+
onClose();
|
|
1737
|
+
}
|
|
1738
|
+
const root = containerRef.current?.getRootNode();
|
|
1739
|
+
const eventTarget =
|
|
1740
|
+
root instanceof ShadowRoot || root instanceof Document ? root : document;
|
|
1741
|
+
eventTarget.addEventListener('pointerdown', handlePointerDown);
|
|
1742
|
+
return () => eventTarget.removeEventListener('pointerdown', handlePointerDown);
|
|
1743
|
+
}, [onClose]);
|
|
1744
|
+
|
|
1745
|
+
return (
|
|
1746
|
+
<div
|
|
1747
|
+
ref={containerRef}
|
|
1748
|
+
role="dialog"
|
|
1749
|
+
aria-label={mode === 'move' ? 'Move notes to folder' : 'Add notes to folder'}
|
|
1750
|
+
className="fixed bottom-16 left-1/2 z-[70] w-[320px] -translate-x-1/2 rounded-lg border border-border bg-popover p-3 text-popover-foreground shadow-xl"
|
|
1751
|
+
>
|
|
1752
|
+
<div className="mb-2 flex items-center justify-between">
|
|
1753
|
+
<span className="text-[12px] font-semibold text-foreground">
|
|
1754
|
+
{mode === 'move' ? 'Move to folder' : 'Add to folder'}
|
|
1755
|
+
</span>
|
|
1756
|
+
<button
|
|
1757
|
+
type="button"
|
|
1758
|
+
aria-label="Close"
|
|
1759
|
+
onClick={onClose}
|
|
1760
|
+
className="inline-flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
|
1761
|
+
>
|
|
1762
|
+
<X className="h-3.5 w-3.5" />
|
|
1763
|
+
</button>
|
|
1764
|
+
</div>
|
|
1765
|
+
<div className="mb-2 flex items-center gap-2 rounded-md border border-border bg-background px-2">
|
|
1766
|
+
<Search className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
1767
|
+
<input
|
|
1768
|
+
autoFocus
|
|
1769
|
+
value={query}
|
|
1770
|
+
onChange={(event) => setQuery(event.target.value)}
|
|
1771
|
+
placeholder="Search folders"
|
|
1772
|
+
className="h-8 min-w-0 flex-1 bg-transparent text-sm outline-none"
|
|
1773
|
+
/>
|
|
1774
|
+
</div>
|
|
1775
|
+
<div className="max-h-[220px] overflow-y-auto">
|
|
1776
|
+
{filtered.length ? (
|
|
1777
|
+
filtered.map((folder) => (
|
|
1778
|
+
<button
|
|
1779
|
+
key={folder.id}
|
|
1780
|
+
type="button"
|
|
1781
|
+
disabled={busy}
|
|
1782
|
+
onClick={() => onPick(folder.id)}
|
|
1783
|
+
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-[13px] text-foreground transition-colors hover:bg-muted disabled:pointer-events-none disabled:opacity-50"
|
|
1784
|
+
>
|
|
1785
|
+
<Folder className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
1786
|
+
<span className="truncate">{folder.pathLabel}</span>
|
|
1787
|
+
</button>
|
|
1788
|
+
))
|
|
1789
|
+
) : (
|
|
1790
|
+
<p className="py-6 text-center text-sm text-muted-foreground">No folders found.</p>
|
|
1791
|
+
)}
|
|
1792
|
+
</div>
|
|
1793
|
+
</div>
|
|
1564
1794
|
);
|
|
1565
1795
|
}
|
|
1566
1796
|
|
|
@@ -1595,7 +1825,7 @@ function TableBody({
|
|
|
1595
1825
|
currentFolderLabel: string;
|
|
1596
1826
|
hasCollectionItem: boolean;
|
|
1597
1827
|
multiSelect: MultiSelectController<DocumentRecord>;
|
|
1598
|
-
rowRefs: Map<string,
|
|
1828
|
+
rowRefs: Map<string, HTMLElement>;
|
|
1599
1829
|
}) {
|
|
1600
1830
|
const columns = metadataProperties.slice(0, 6);
|
|
1601
1831
|
const allSelected =
|
|
@@ -1610,21 +1840,6 @@ function TableBody({
|
|
|
1610
1840
|
|
|
1611
1841
|
return (
|
|
1612
1842
|
<div className="flex flex-1 flex-col gap-4 px-6 py-5">
|
|
1613
|
-
<div className="flex flex-wrap items-center gap-2">
|
|
1614
|
-
<FilterChip icon={Settings2} label="Status is" value="Drafting, Review" />
|
|
1615
|
-
<FilterChip icon={CalendarDays} label="Due" value="this week" />
|
|
1616
|
-
<FilterChip icon={Rows3} label="Sort by" value="Updated ↓" />
|
|
1617
|
-
<button
|
|
1618
|
-
type="button"
|
|
1619
|
-
className="inline-flex items-center gap-1.5 rounded-full border border-dashed border-border px-2.5 py-1 text-[11px] font-medium text-muted-foreground transition-colors hover:border-border hover:text-foreground"
|
|
1620
|
-
>
|
|
1621
|
-
<Plus className="h-3 w-3" />
|
|
1622
|
-
Add filter
|
|
1623
|
-
</button>
|
|
1624
|
-
<div className="flex-1" />
|
|
1625
|
-
<Eyebrow>Hide · Tags, Author</Eyebrow>
|
|
1626
|
-
</div>
|
|
1627
|
-
|
|
1628
1843
|
{!notes.length ? (
|
|
1629
1844
|
<EmptyState
|
|
1630
1845
|
icon={Table2}
|
|
@@ -1697,45 +1912,10 @@ function TableBody({
|
|
|
1697
1912
|
</div>
|
|
1698
1913
|
</div>
|
|
1699
1914
|
)}
|
|
1700
|
-
|
|
1701
|
-
<div className="flex flex-wrap items-center gap-3 rounded-lg border border-border bg-card px-4 py-3">
|
|
1702
|
-
<div className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-md bg-muted">
|
|
1703
|
-
<Sparkles className="h-3.5 w-3.5 text-foreground" />
|
|
1704
|
-
</div>
|
|
1705
|
-
<div className="min-w-0 flex-1">
|
|
1706
|
-
<p className="text-[13px] font-semibold text-foreground">Need a different view?</p>
|
|
1707
|
-
<p className="text-[12px] text-muted-foreground">
|
|
1708
|
-
Ask Notis to add a column, group by folder, hide shipped rows, or sort differently. No settings maze.
|
|
1709
|
-
</p>
|
|
1710
|
-
</div>
|
|
1711
|
-
<div className="flex flex-wrap gap-2">
|
|
1712
|
-
<AgentHintChip>group by Folder</AgentHintChip>
|
|
1713
|
-
<AgentHintChip>hide Updated</AgentHintChip>
|
|
1714
|
-
<AgentHintChip>sort by Words</AgentHintChip>
|
|
1715
|
-
</div>
|
|
1716
|
-
</div>
|
|
1717
1915
|
</div>
|
|
1718
1916
|
);
|
|
1719
1917
|
}
|
|
1720
1918
|
|
|
1721
|
-
function FilterChip({ icon: Icon, label, value }: { icon: Icon; label: string; value: string }) {
|
|
1722
|
-
return (
|
|
1723
|
-
<span className="inline-flex items-center gap-1.5 rounded-full border border-border bg-background px-2.5 py-1 text-[11px]">
|
|
1724
|
-
<Icon className="h-3 w-3 text-muted-foreground" />
|
|
1725
|
-
<span className="text-muted-foreground">{label}</span>
|
|
1726
|
-
<span className="font-semibold text-foreground">{value}</span>
|
|
1727
|
-
</span>
|
|
1728
|
-
);
|
|
1729
|
-
}
|
|
1730
|
-
|
|
1731
|
-
function AgentHintChip({ children }: { children: React.ReactNode }) {
|
|
1732
|
-
return (
|
|
1733
|
-
<span className="rounded-full bg-muted px-2.5 py-1 font-mono text-[10px] text-foreground/80">
|
|
1734
|
-
{children}
|
|
1735
|
-
</span>
|
|
1736
|
-
);
|
|
1737
|
-
}
|
|
1738
|
-
|
|
1739
1919
|
function TableRow({
|
|
1740
1920
|
note,
|
|
1741
1921
|
columns,
|
|
@@ -1976,27 +2156,23 @@ function TableCell({
|
|
|
1976
2156
|
/* -------------------------------------------------------------------------- */
|
|
1977
2157
|
|
|
1978
2158
|
function CalendarBody({
|
|
1979
|
-
notes,
|
|
1980
2159
|
monthDays,
|
|
1981
2160
|
visibleMonth,
|
|
1982
2161
|
setVisibleMonth,
|
|
1983
2162
|
notesByDay,
|
|
1984
2163
|
statusPropertyName,
|
|
1985
|
-
|
|
1986
|
-
dateProperties,
|
|
2164
|
+
calendarDateFields,
|
|
1987
2165
|
activeDateProperty,
|
|
1988
2166
|
setActiveDateProperty,
|
|
1989
2167
|
scheduledNotesCount,
|
|
1990
2168
|
onOpen,
|
|
1991
2169
|
}: {
|
|
1992
|
-
notes: DocumentRecord[];
|
|
1993
2170
|
monthDays: Date[];
|
|
1994
2171
|
visibleMonth: Date;
|
|
1995
2172
|
setVisibleMonth: (d: Date) => void;
|
|
1996
2173
|
notesByDay: Map<string, DocumentRecord[]>;
|
|
1997
2174
|
statusPropertyName: string | null;
|
|
1998
|
-
|
|
1999
|
-
dateProperties: DatabaseProperty[];
|
|
2175
|
+
calendarDateFields: CalendarDateField[];
|
|
2000
2176
|
activeDateProperty: string;
|
|
2001
2177
|
setActiveDateProperty: (v: string) => void;
|
|
2002
2178
|
scheduledNotesCount: number;
|
|
@@ -2035,19 +2211,14 @@ function CalendarBody({
|
|
|
2035
2211
|
<span className="text-muted-foreground">Date field</span>
|
|
2036
2212
|
<select
|
|
2037
2213
|
className="appearance-none bg-transparent pr-3 font-semibold text-foreground outline-none"
|
|
2038
|
-
value={
|
|
2214
|
+
value={activeDateProperty}
|
|
2039
2215
|
onChange={(e) => setActiveDateProperty(e.target.value)}
|
|
2040
|
-
disabled={!dateProperties.length}
|
|
2041
2216
|
>
|
|
2042
|
-
{
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
))
|
|
2048
|
-
) : (
|
|
2049
|
-
<option value="">—</option>
|
|
2050
|
-
)}
|
|
2217
|
+
{calendarDateFields.map((field) => (
|
|
2218
|
+
<option key={field.value} value={field.value}>
|
|
2219
|
+
{field.label}
|
|
2220
|
+
</option>
|
|
2221
|
+
))}
|
|
2051
2222
|
</select>
|
|
2052
2223
|
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
|
2053
2224
|
</div>
|
|
@@ -2056,14 +2227,7 @@ function CalendarBody({
|
|
|
2056
2227
|
<Eyebrow>{pluralize(scheduledNotesCount, 'note')} scheduled</Eyebrow>
|
|
2057
2228
|
</div>
|
|
2058
2229
|
|
|
2059
|
-
|
|
2060
|
-
<EmptyState
|
|
2061
|
-
icon={CalendarDays}
|
|
2062
|
-
title="No date property yet"
|
|
2063
|
-
description="Add a date field to the notes database and this board will start plotting notes automatically."
|
|
2064
|
-
/>
|
|
2065
|
-
) : (
|
|
2066
|
-
<div className="overflow-hidden rounded-lg border border-border bg-card">
|
|
2230
|
+
<div className="overflow-hidden rounded-lg border border-border bg-card">
|
|
2067
2231
|
<div className="grid grid-cols-7 border-b border-border bg-muted/40">
|
|
2068
2232
|
{WEEKDAY_LABELS.map((label, i) => (
|
|
2069
2233
|
<div
|
|
@@ -2136,33 +2300,7 @@ function CalendarBody({
|
|
|
2136
2300
|
);
|
|
2137
2301
|
})}
|
|
2138
2302
|
</div>
|
|
2139
|
-
</div>
|
|
2140
|
-
)}
|
|
2141
|
-
|
|
2142
|
-
<div className="flex flex-wrap items-center gap-4 rounded-lg border border-border bg-card px-4 py-3">
|
|
2143
|
-
<Eyebrow>Legend</Eyebrow>
|
|
2144
|
-
<LegendItem color="bg-amber-500" label="Drafting" />
|
|
2145
|
-
<LegendItem color="bg-blue-500" label="Review" />
|
|
2146
|
-
<LegendItem color="bg-emerald-500" label="Shipped" />
|
|
2147
|
-
<LegendItem color="bg-stone-400" label="Idea" />
|
|
2148
|
-
<div className="flex-1" />
|
|
2149
|
-
<div className="flex flex-wrap items-center gap-2">
|
|
2150
|
-
<Sparkles className="h-3.5 w-3.5 text-foreground" />
|
|
2151
|
-
<span className="text-[12px] text-muted-foreground">Ask Notis to</span>
|
|
2152
|
-
<AgentHintChip>use Publish date</AgentHintChip>
|
|
2153
|
-
<AgentHintChip>color by Folder</AgentHintChip>
|
|
2154
|
-
<AgentHintChip>switch to week view</AgentHintChip>
|
|
2155
|
-
</div>
|
|
2156
2303
|
</div>
|
|
2157
2304
|
</div>
|
|
2158
2305
|
);
|
|
2159
2306
|
}
|
|
2160
|
-
|
|
2161
|
-
function LegendItem({ color, label }: { color: string; label: string }) {
|
|
2162
|
-
return (
|
|
2163
|
-
<div className="flex items-center gap-1.5">
|
|
2164
|
-
<span className={cn('h-0.5 w-3 rounded', color)} />
|
|
2165
|
-
<span className="text-[11px] font-medium text-foreground/80">{label}</span>
|
|
2166
|
-
</div>
|
|
2167
|
-
);
|
|
2168
|
-
}
|