nebula-cms 0.1.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/.claude/settings.local.json +42 -0
- package/.github/workflows/ci.yml +31 -0
- package/.mcp.json +12 -0
- package/.prettierignore +5 -0
- package/.prettierrc.cjs +22 -0
- package/AGENTS.md +183 -0
- package/LICENSE +201 -0
- package/README.md +128 -0
- package/package.json +74 -0
- package/playground/.claude/settings.local.json +5 -0
- package/playground/astro.config.mjs +7 -0
- package/playground/node_modules/.bin/astro +21 -0
- package/playground/node_modules/.bin/rollup +21 -0
- package/playground/node_modules/.bin/tsc +21 -0
- package/playground/node_modules/.bin/tsserver +21 -0
- package/playground/node_modules/.bin/vite +21 -0
- package/playground/node_modules/.vite/_svelte_metadata.json +1 -0
- package/playground/node_modules/.vite/deps/@astrojs_svelte_client__js.js +80 -0
- package/playground/node_modules/.vite/deps/@astrojs_svelte_client__js.js.map +7 -0
- package/playground/node_modules/.vite/deps/_metadata.json +184 -0
- package/playground/node_modules/.vite/deps/astro___aria-query.js +6776 -0
- package/playground/node_modules/.vite/deps/astro___aria-query.js.map +7 -0
- package/playground/node_modules/.vite/deps/astro___axobject-query.js +3754 -0
- package/playground/node_modules/.vite/deps/astro___axobject-query.js.map +7 -0
- package/playground/node_modules/.vite/deps/astro___html-escaper.js +34 -0
- package/playground/node_modules/.vite/deps/astro___html-escaper.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-AJXJMYAF.js +0 -0
- package/playground/node_modules/.vite/deps/chunk-AJXJMYAF.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-ALJIOON6.js +1005 -0
- package/playground/node_modules/.vite/deps/chunk-ALJIOON6.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-BUSYA2B4.js +8 -0
- package/playground/node_modules/.vite/deps/chunk-BUSYA2B4.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-CNYJBM5F.js +21 -0
- package/playground/node_modules/.vite/deps/chunk-CNYJBM5F.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-DBPNBGEI.js +223 -0
- package/playground/node_modules/.vite/deps/chunk-DBPNBGEI.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-G3C2FXJT.js +204 -0
- package/playground/node_modules/.vite/deps/chunk-G3C2FXJT.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-GKDKFWC5.js +27 -0
- package/playground/node_modules/.vite/deps/chunk-GKDKFWC5.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-HNCLEOC5.js +4376 -0
- package/playground/node_modules/.vite/deps/chunk-HNCLEOC5.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-JICYXBFU.js +688 -0
- package/playground/node_modules/.vite/deps/chunk-JICYXBFU.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-KCUTL6DD.js +5099 -0
- package/playground/node_modules/.vite/deps/chunk-KCUTL6DD.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-ZP4UNCSN.js +23 -0
- package/playground/node_modules/.vite/deps/chunk-ZP4UNCSN.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-ZREFNRZZ.js +148 -0
- package/playground/node_modules/.vite/deps/chunk-ZREFNRZZ.js.map +7 -0
- package/playground/node_modules/.vite/deps/package.json +3 -0
- package/playground/node_modules/.vite/deps/smol-toml.js +843 -0
- package/playground/node_modules/.vite/deps/smol-toml.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte.js +55 -0
- package/playground/node_modules/.vite/deps/svelte.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte___clsx.js +9 -0
- package/playground/node_modules/.vite/deps/svelte___clsx.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_animate.js +57 -0
- package/playground/node_modules/.vite/deps/svelte_animate.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_attachments.js +15 -0
- package/playground/node_modules/.vite/deps/svelte_attachments.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_easing.js +67 -0
- package/playground/node_modules/.vite/deps/svelte_easing.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_events.js +11 -0
- package/playground/node_modules/.vite/deps/svelte_events.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_internal.js +5 -0
- package/playground/node_modules/.vite/deps/svelte_internal.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_internal_client.js +402 -0
- package/playground/node_modules/.vite/deps/svelte_internal_client.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_internal_disclose-version.js +10 -0
- package/playground/node_modules/.vite/deps/svelte_internal_disclose-version.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_internal_flags_async.js +8 -0
- package/playground/node_modules/.vite/deps/svelte_internal_flags_async.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_internal_flags_legacy.js +8 -0
- package/playground/node_modules/.vite/deps/svelte_internal_flags_legacy.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_internal_flags_tracing.js +8 -0
- package/playground/node_modules/.vite/deps/svelte_internal_flags_tracing.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_legacy.js +35 -0
- package/playground/node_modules/.vite/deps/svelte_legacy.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_motion.js +545 -0
- package/playground/node_modules/.vite/deps/svelte_motion.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_reactivity.js +29 -0
- package/playground/node_modules/.vite/deps/svelte_reactivity.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_reactivity_window.js +127 -0
- package/playground/node_modules/.vite/deps/svelte_reactivity_window.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_store.js +103 -0
- package/playground/node_modules/.vite/deps/svelte_store.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_transition.js +208 -0
- package/playground/node_modules/.vite/deps/svelte_transition.js.map +7 -0
- package/playground/package.json +16 -0
- package/playground/pnpm-lock.yaml +3167 -0
- package/playground/src/content/authors/jane-doe.json +8 -0
- package/playground/src/content/config/build.toml +2 -0
- package/playground/src/content/courses/web-fundamentals.json +29 -0
- package/playground/src/content/docs/advanced.mdx +6 -0
- package/playground/src/content/docs/intro.md +6 -0
- package/playground/src/content/guides/getting-started.mdx +6 -0
- package/playground/src/content/posts/hello-world.md +7 -0
- package/playground/src/content/products/t-shirt.json +16 -0
- package/playground/src/content/recipes/pancakes.mdoc +8 -0
- package/playground/src/content/settings/site.yml +2 -0
- package/playground/src/content.config.ts +198 -0
- package/playground/src/env.d.ts +1 -0
- package/playground/src/pages/index.astro +11 -0
- package/playground/src/pages/nebula.astro +14 -0
- package/pnpm-workspace.yaml +2 -0
- package/scripts/subset-icons.mjs +178 -0
- package/src/astro/index.ts +295 -0
- package/src/client/Admin.svelte +283 -0
- package/src/client/components/BackendPicker.svelte +291 -0
- package/src/client/components/DraftChip.svelte +46 -0
- package/src/client/components/MetadataForm.svelte +56 -0
- package/src/client/components/ThemeToggle.svelte +18 -0
- package/src/client/components/dialogs/DeleteDraftDialog.svelte +51 -0
- package/src/client/components/dialogs/FilenameDialog.svelte +129 -0
- package/src/client/components/editor/EditorPane.svelte +227 -0
- package/src/client/components/editor/EditorTabs.svelte +81 -0
- package/src/client/components/editor/EditorToolbar.svelte +131 -0
- package/src/client/components/editor/FormatSelector.svelte +66 -0
- package/src/client/components/editor/Toolbar.svelte +17 -0
- package/src/client/components/fields/ArrayField.svelte +339 -0
- package/src/client/components/fields/ArrayItem.svelte +325 -0
- package/src/client/components/fields/BooleanField.svelte +114 -0
- package/src/client/components/fields/DateField.svelte +82 -0
- package/src/client/components/fields/EnumField.svelte +74 -0
- package/src/client/components/fields/FieldWrapper.svelte +96 -0
- package/src/client/components/fields/NumberField.svelte +99 -0
- package/src/client/components/fields/ObjectField.svelte +121 -0
- package/src/client/components/fields/SchemaField.svelte +107 -0
- package/src/client/components/fields/StringField.svelte +104 -0
- package/src/client/components/sidebar/AdminSidebar.svelte +339 -0
- package/src/client/components/sidebar/AdminSidebarSort.svelte +123 -0
- package/src/client/css/a11y.css +14 -0
- package/src/client/css/btn.css +113 -0
- package/src/client/css/dialog.css +29 -0
- package/src/client/css/field-input.css +39 -0
- package/src/client/css/reset.css +59 -0
- package/src/client/css/theme.css +77 -0
- package/src/client/index.ts +1 -0
- package/src/client/js/drafts/merge.svelte.ts +121 -0
- package/src/client/js/drafts/ops.svelte.ts +227 -0
- package/src/client/js/drafts/storage.ts +108 -0
- package/src/client/js/drafts/workers/diff.ts +40 -0
- package/src/client/js/editor/editor.svelte.ts +343 -0
- package/src/client/js/editor/languages.ts +98 -0
- package/src/client/js/editor/link-wrap.ts +45 -0
- package/src/client/js/editor/markdown-shortcuts.ts +261 -0
- package/src/client/js/handlers/admin.ts +246 -0
- package/src/client/js/state/dialogs.svelte.ts +35 -0
- package/src/client/js/state/router.svelte.ts +156 -0
- package/src/client/js/state/schema.svelte.ts +140 -0
- package/src/client/js/state/state.svelte.ts +334 -0
- package/src/client/js/state/theme.svelte.ts +173 -0
- package/src/client/js/storage/adapter.ts +102 -0
- package/src/client/js/storage/client.ts +150 -0
- package/src/client/js/storage/db.ts +36 -0
- package/src/client/js/storage/fsa.ts +110 -0
- package/src/client/js/storage/github.ts +297 -0
- package/src/client/js/storage/storage.ts +83 -0
- package/src/client/js/storage/workers/frontmatter.ts +320 -0
- package/src/client/js/storage/workers/storage.ts +177 -0
- package/src/client/js/storage/workers/toml-parser.ts +106 -0
- package/src/client/js/storage/workers/yaml-parser.ts +132 -0
- package/src/client/js/utils/file-types.ts +192 -0
- package/src/client/js/utils/format.ts +16 -0
- package/src/client/js/utils/frontmatter.ts +38 -0
- package/src/client/js/utils/schema-utils.ts +295 -0
- package/src/client/js/utils/slug.ts +18 -0
- package/src/client/js/utils/sort.ts +84 -0
- package/src/client/js/utils/stable-stringify.ts +27 -0
- package/src/client/js/utils/url-utils.ts +38 -0
- package/src/types.ts +25 -0
- package/src/virtual.d.ts +22 -0
- package/svelte.config.js +4 -0
- package/tests/astro/build.test.ts +63 -0
- package/tests/astro/index.test.ts +689 -0
- package/tests/client/components/Admin.test.ts +446 -0
- package/tests/client/components/BackendPicker.test.ts +239 -0
- package/tests/client/components/DraftChip.test.ts +53 -0
- package/tests/client/components/MetadataForm.test.ts +164 -0
- package/tests/client/components/dialogs/DeleteDraftDialog.test.ts +91 -0
- package/tests/client/components/dialogs/FilenameDialog.test.ts +209 -0
- package/tests/client/components/dialogs/dialog-stubs.ts +19 -0
- package/tests/client/components/editor/EditorPane.test.ts +100 -0
- package/tests/client/components/editor/EditorTabs.test.ts +253 -0
- package/tests/client/components/editor/EditorToolbar.test.ts +252 -0
- package/tests/client/components/editor/fixtures.ts +31 -0
- package/tests/client/components/fields/ArrayField.test.ts +197 -0
- package/tests/client/components/fields/BooleanField.test.ts +206 -0
- package/tests/client/components/fields/DateField.test.ts +210 -0
- package/tests/client/components/fields/EnumField.test.ts +246 -0
- package/tests/client/components/fields/NumberField.test.ts +240 -0
- package/tests/client/components/fields/ObjectField.test.ts +157 -0
- package/tests/client/components/fields/SchemaField.test.ts +190 -0
- package/tests/client/components/fields/StringField.test.ts +223 -0
- package/tests/client/components/sidebar/AdminSidebar.test.ts +285 -0
- package/tests/client/components/sidebar/AdminSidebarSort.test.ts +135 -0
- package/tests/client/components/sidebar/sort-mock.ts +23 -0
- package/tests/client/js/drafts/fixtures.ts +22 -0
- package/tests/client/js/drafts/merge.test.ts +282 -0
- package/tests/client/js/drafts/ops.test.ts +658 -0
- package/tests/client/js/drafts/storage.test.ts +200 -0
- package/tests/client/js/drafts/workers/diff.test.ts +165 -0
- package/tests/client/js/editor/editor.test.ts +616 -0
- package/tests/client/js/editor/link-wrap.test.ts +225 -0
- package/tests/client/js/editor/markdown-shortcuts.test.ts +370 -0
- package/tests/client/js/handlers/admin.test.ts +467 -0
- package/tests/client/js/state/router.test.ts +619 -0
- package/tests/client/js/state/schema.test.ts +266 -0
- package/tests/client/js/state/state.test.ts +328 -0
- package/tests/client/js/storage/adapter.test.ts +115 -0
- package/tests/client/js/storage/client.test.ts +250 -0
- package/tests/client/js/storage/db.test.ts +59 -0
- package/tests/client/js/storage/fsa.test.ts +284 -0
- package/tests/client/js/storage/github.test.ts +349 -0
- package/tests/client/js/storage/mock-port.ts +95 -0
- package/tests/client/js/storage/storage.test.ts +77 -0
- package/tests/client/js/storage/workers/frontmatter.test.ts +479 -0
- package/tests/client/js/storage/workers/storage.test.ts +299 -0
- package/tests/client/js/storage/workers/toml-parser.test.ts +169 -0
- package/tests/client/js/storage/workers/yaml-parser.test.ts +168 -0
- package/tests/client/js/utils/file-types.test.ts +268 -0
- package/tests/client/js/utils/frontmatter.test.ts +87 -0
- package/tests/client/js/utils/schema-utils.test.ts +318 -0
- package/tests/client/js/utils/slug.test.ts +58 -0
- package/tests/client/js/utils/sort.test.ts +276 -0
- package/tests/client/js/utils/stable-stringify.test.ts +68 -0
- package/tests/client/js/utils/url-utils.test.ts +70 -0
- package/tests/e2e/backend-connection.test.ts +301 -0
- package/tests/e2e/draft-lifecycle.test.ts +388 -0
- package/tests/e2e/editing.test.ts +355 -0
- package/tests/e2e/github-adapter.test.ts +330 -0
- package/tests/e2e/helpers/mock-adapter.ts +166 -0
- package/tests/e2e/helpers/test-app.ts +155 -0
- package/tests/e2e/navigation.test.ts +358 -0
- package/tests/e2e/publishing.test.ts +345 -0
- package/tests/e2e/unsaved-changes.test.ts +317 -0
- package/tests/setup.ts +2 -0
- package/tests/stubs/codemirror.ts +197 -0
- package/tsconfig.json +19 -0
- package/vitest.config.ts +178 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { SchemaNode } from '../../js/utils/schema-utils';
|
|
3
|
+
import { isReadOnly, isNullable } from '../../js/utils/schema-utils';
|
|
4
|
+
import FieldWrapper from './FieldWrapper.svelte';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Props for the StringField component, which renders a text input or textarea for a JSON Schema string property.
|
|
8
|
+
*/
|
|
9
|
+
interface Props {
|
|
10
|
+
// Field name used as the input id and label fallback
|
|
11
|
+
name: string;
|
|
12
|
+
// JSON Schema node describing this field
|
|
13
|
+
schema: SchemaNode;
|
|
14
|
+
// Current field value
|
|
15
|
+
value: unknown;
|
|
16
|
+
// Whether this field is required
|
|
17
|
+
required?: boolean;
|
|
18
|
+
// Callback fired when the value changes
|
|
19
|
+
onchange: (value: string | null) => void;
|
|
20
|
+
// When true, visually hides FieldWrapper chrome (label/help) for inline array contexts
|
|
21
|
+
inline?: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let {
|
|
25
|
+
name,
|
|
26
|
+
schema,
|
|
27
|
+
value,
|
|
28
|
+
required = false,
|
|
29
|
+
onchange,
|
|
30
|
+
inline = false,
|
|
31
|
+
}: Props = $props();
|
|
32
|
+
|
|
33
|
+
// Current string value for the input
|
|
34
|
+
const inputValue = $derived(typeof value === 'string' ? value : '');
|
|
35
|
+
|
|
36
|
+
// Max length constraint from schema, if any
|
|
37
|
+
const maxLength = $derived(schema['maxLength'] as number | undefined);
|
|
38
|
+
|
|
39
|
+
// Pattern constraint from schema, if any
|
|
40
|
+
const pattern = $derived(schema['pattern'] as string | undefined);
|
|
41
|
+
|
|
42
|
+
// Whether field is read-only
|
|
43
|
+
const readOnly = $derived(isReadOnly(schema));
|
|
44
|
+
|
|
45
|
+
// Whether empty input should emit null (nullable anyOf-unwrapped types)
|
|
46
|
+
const nullable = $derived(isNullable(schema));
|
|
47
|
+
|
|
48
|
+
// Whether to render as a textarea (widget: "textarea" in schema meta)
|
|
49
|
+
const isTextarea = $derived(schema['widget'] === 'textarea');
|
|
50
|
+
|
|
51
|
+
// Constraint text for the help line
|
|
52
|
+
const constraintText = $derived(
|
|
53
|
+
maxLength != null ? `max ${maxLength}` : undefined,
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Handles input change, emitting null for empty nullable fields.
|
|
58
|
+
* @param {Event} e - The input or change event from the text input or textarea
|
|
59
|
+
* @return {void}
|
|
60
|
+
*/
|
|
61
|
+
function handleChange(e: Event): void {
|
|
62
|
+
const raw = (e.target as HTMLInputElement | HTMLTextAreaElement).value;
|
|
63
|
+
onchange(nullable && raw === '' ? null : raw);
|
|
64
|
+
}
|
|
65
|
+
</script>
|
|
66
|
+
|
|
67
|
+
<FieldWrapper {name} {schema} {required} {constraintText} {inline}>
|
|
68
|
+
{#if isTextarea}
|
|
69
|
+
<textarea
|
|
70
|
+
id={name}
|
|
71
|
+
class="field-input field-input--textarea"
|
|
72
|
+
maxlength={maxLength}
|
|
73
|
+
readonly={readOnly}
|
|
74
|
+
rows={3}
|
|
75
|
+
oninput={handleChange}>{inputValue}</textarea
|
|
76
|
+
>
|
|
77
|
+
{:else}
|
|
78
|
+
<input
|
|
79
|
+
type="text"
|
|
80
|
+
id={name}
|
|
81
|
+
class="field-input field-input--text"
|
|
82
|
+
value={inputValue}
|
|
83
|
+
maxlength={maxLength}
|
|
84
|
+
{pattern}
|
|
85
|
+
readonly={readOnly}
|
|
86
|
+
oninput={handleChange}
|
|
87
|
+
/>
|
|
88
|
+
{/if}
|
|
89
|
+
</FieldWrapper>
|
|
90
|
+
|
|
91
|
+
<style>
|
|
92
|
+
.field-input--text {
|
|
93
|
+
width: 100%;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/* Auto-grows with content; rows="3" sets minimum height */
|
|
97
|
+
.field-input--textarea {
|
|
98
|
+
width: 100%;
|
|
99
|
+
field-sizing: content;
|
|
100
|
+
resize: none;
|
|
101
|
+
font-family: inherit;
|
|
102
|
+
line-height: 1.5;
|
|
103
|
+
}
|
|
104
|
+
</style>
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import {
|
|
3
|
+
type SidebarItem,
|
|
4
|
+
type SortMode,
|
|
5
|
+
readSortMode,
|
|
6
|
+
createComparator,
|
|
7
|
+
} from '../../js/utils/sort';
|
|
8
|
+
import DraftChip from '../DraftChip.svelte';
|
|
9
|
+
import AdminSidebarSort from './AdminSidebarSort.svelte';
|
|
10
|
+
import { navigate, adminPath } from '../../js/state/router.svelte';
|
|
11
|
+
import { saveDraft } from '../../js/drafts/storage';
|
|
12
|
+
import { refreshDrafts, disconnect } from '../../js/state/state.svelte';
|
|
13
|
+
import ThemeToggle from '../ThemeToggle.svelte';
|
|
14
|
+
|
|
15
|
+
export type { SidebarItem };
|
|
16
|
+
|
|
17
|
+
// Props for the AdminSidebar component, which renders a filterable, sortable navigation list of collection items with a search input and optional sort popover.
|
|
18
|
+
interface Props {
|
|
19
|
+
// Heading text displayed at the top of the sidebar
|
|
20
|
+
title: string;
|
|
21
|
+
// Items to display in the sidebar list
|
|
22
|
+
items: SidebarItem[];
|
|
23
|
+
// href of the currently active item, highlighted with aria-current
|
|
24
|
+
activeItem?: string;
|
|
25
|
+
// Collection name for localStorage sort persistence (constructs key: cms-sort-{storageKey})
|
|
26
|
+
storageKey?: string;
|
|
27
|
+
// Whether items are currently loading
|
|
28
|
+
loading?: boolean;
|
|
29
|
+
// Error message to display instead of items
|
|
30
|
+
error?: string;
|
|
31
|
+
// Whether this collection has date fields, enabling sort controls
|
|
32
|
+
hasDates?: boolean;
|
|
33
|
+
// Collection name — used for the add button's navigation target
|
|
34
|
+
collection?: string;
|
|
35
|
+
// Whether to show the add button
|
|
36
|
+
showAdd?: boolean;
|
|
37
|
+
// Whether to show the logout footer at the bottom
|
|
38
|
+
showFooter?: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let {
|
|
42
|
+
title,
|
|
43
|
+
items,
|
|
44
|
+
activeItem,
|
|
45
|
+
storageKey,
|
|
46
|
+
loading = false,
|
|
47
|
+
error,
|
|
48
|
+
hasDates = false,
|
|
49
|
+
collection,
|
|
50
|
+
showAdd = false,
|
|
51
|
+
showFooter = false,
|
|
52
|
+
}: Props = $props();
|
|
53
|
+
|
|
54
|
+
// Search query for filtering items by label
|
|
55
|
+
let searchQuery = $state('');
|
|
56
|
+
|
|
57
|
+
// Current sort mode, initialized from localStorage if storageKey is provided
|
|
58
|
+
let sortMode = $state<SortMode>(
|
|
59
|
+
storageKey ? readSortMode(storageKey) : 'alpha',
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
// Re-read sort mode when storageKey changes (switching collections)
|
|
63
|
+
$effect(() => {
|
|
64
|
+
if (storageKey) {
|
|
65
|
+
sortMode = readSortMode(storageKey);
|
|
66
|
+
} else {
|
|
67
|
+
sortMode = 'alpha';
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Creates a new empty draft in IndexedDB and navigates to it.
|
|
73
|
+
* @return {Promise<void>}
|
|
74
|
+
*/
|
|
75
|
+
async function handleAdd(): Promise<void> {
|
|
76
|
+
if (!collection) return;
|
|
77
|
+
const id = crypto.randomUUID();
|
|
78
|
+
await saveDraft({
|
|
79
|
+
id,
|
|
80
|
+
collection,
|
|
81
|
+
filename: null,
|
|
82
|
+
isNew: true,
|
|
83
|
+
formData: {},
|
|
84
|
+
body: '',
|
|
85
|
+
snapshot: null,
|
|
86
|
+
createdAt: new Date().toISOString(),
|
|
87
|
+
});
|
|
88
|
+
/*
|
|
89
|
+
* Only refresh drafts — the live file list hasn't changed, so a full
|
|
90
|
+
* collection reload (which re-reads all files from disk/GitHub) is wasteful.
|
|
91
|
+
*/
|
|
92
|
+
await refreshDrafts(collection);
|
|
93
|
+
navigate(adminPath(collection, `draft-${id}`));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Handles the logout button click by disconnecting the backend.
|
|
98
|
+
* @return {void}
|
|
99
|
+
*/
|
|
100
|
+
function onLogout(): void {
|
|
101
|
+
disconnect();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Items filtered by search query and sorted by current mode
|
|
105
|
+
const displayedItems = $derived.by(() => {
|
|
106
|
+
const query = searchQuery.toLowerCase();
|
|
107
|
+
const filtered = query
|
|
108
|
+
? items.filter((item) => item.label.toLowerCase().includes(query))
|
|
109
|
+
: items;
|
|
110
|
+
return [...filtered].sort(createComparator(sortMode));
|
|
111
|
+
});
|
|
112
|
+
</script>
|
|
113
|
+
|
|
114
|
+
<nav class="sidebar" aria-label={title}>
|
|
115
|
+
<div class="sidebar-header">
|
|
116
|
+
<div class="sidebar-heading-row">
|
|
117
|
+
<h2 class="sidebar-heading">{title}</h2>
|
|
118
|
+
{#if showAdd}
|
|
119
|
+
<button
|
|
120
|
+
class="icon-btn add-btn"
|
|
121
|
+
title="New {title.toLowerCase()}"
|
|
122
|
+
onclick={handleAdd}
|
|
123
|
+
>
|
|
124
|
+
<span class="icon">add</span>
|
|
125
|
+
</button>
|
|
126
|
+
{/if}
|
|
127
|
+
</div>
|
|
128
|
+
|
|
129
|
+
<div class="toolbar" class:toolbar--search-only={!hasDates}>
|
|
130
|
+
<input
|
|
131
|
+
type="text"
|
|
132
|
+
class="search-input"
|
|
133
|
+
placeholder="Filter..."
|
|
134
|
+
bind:value={searchQuery}
|
|
135
|
+
/>
|
|
136
|
+
|
|
137
|
+
{#if hasDates}
|
|
138
|
+
<AdminSidebarSort bind:sortMode {storageKey} />
|
|
139
|
+
{/if}
|
|
140
|
+
</div>
|
|
141
|
+
</div>
|
|
142
|
+
|
|
143
|
+
<div class="sidebar-items">
|
|
144
|
+
{#if loading}
|
|
145
|
+
<p class="status">Loading...</p>
|
|
146
|
+
{:else if error}
|
|
147
|
+
<p class="status status--error">{error}</p>
|
|
148
|
+
{:else if displayedItems.length === 0}
|
|
149
|
+
<p class="status">No items found.</p>
|
|
150
|
+
{:else}
|
|
151
|
+
<ul class="sidebar-list">
|
|
152
|
+
{#each displayedItems as item}
|
|
153
|
+
<li>
|
|
154
|
+
<a
|
|
155
|
+
href={item.href}
|
|
156
|
+
class="sidebar-link"
|
|
157
|
+
aria-current={activeItem === item.href ? 'page' : undefined}
|
|
158
|
+
>
|
|
159
|
+
<span class="item-label-row">
|
|
160
|
+
<span class="item-label-text">{item.label}</span>
|
|
161
|
+
{#if item.isDraft}
|
|
162
|
+
<DraftChip variant="draft" />
|
|
163
|
+
{/if}
|
|
164
|
+
{#if item.isOutdated}
|
|
165
|
+
<DraftChip variant="outdated" />
|
|
166
|
+
{/if}
|
|
167
|
+
</span>
|
|
168
|
+
{#if item.subtitle}
|
|
169
|
+
<span class="item-subtitle">{item.subtitle}</span>
|
|
170
|
+
{/if}
|
|
171
|
+
</a>
|
|
172
|
+
</li>
|
|
173
|
+
{/each}
|
|
174
|
+
</ul>
|
|
175
|
+
{/if}
|
|
176
|
+
</div>
|
|
177
|
+
|
|
178
|
+
{#if showFooter}
|
|
179
|
+
<div class="sidebar-footer">
|
|
180
|
+
<button class="logout-btn" onclick={onLogout}>
|
|
181
|
+
<span class="icon">logout</span>
|
|
182
|
+
<span>Log out</span>
|
|
183
|
+
</button>
|
|
184
|
+
<ThemeToggle />
|
|
185
|
+
</div>
|
|
186
|
+
{/if}
|
|
187
|
+
</nav>
|
|
188
|
+
|
|
189
|
+
<style>
|
|
190
|
+
.sidebar {
|
|
191
|
+
display: grid;
|
|
192
|
+
grid-template-rows: auto 1fr auto;
|
|
193
|
+
height: 100dvh;
|
|
194
|
+
border-right: 1px solid var(--cms-border);
|
|
195
|
+
position: sticky;
|
|
196
|
+
top: 0;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
.sidebar-header {
|
|
200
|
+
padding: 1rem;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
.sidebar-heading {
|
|
204
|
+
font-size: 0.875rem;
|
|
205
|
+
text-transform: uppercase;
|
|
206
|
+
letter-spacing: 0.05em;
|
|
207
|
+
color: var(--cms-muted);
|
|
208
|
+
margin-bottom: 0;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
.toolbar {
|
|
212
|
+
display: grid;
|
|
213
|
+
grid-template-columns: 1fr auto;
|
|
214
|
+
gap: 0.5rem;
|
|
215
|
+
align-items: center;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
.toolbar--search-only {
|
|
219
|
+
grid-template-columns: 1fr;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
.search-input {
|
|
223
|
+
width: 100%;
|
|
224
|
+
padding: 0.25rem 0.5rem;
|
|
225
|
+
background: var(--cms-bg);
|
|
226
|
+
border: 1px solid var(--cms-border);
|
|
227
|
+
border-radius: 0.25rem;
|
|
228
|
+
color: var(--cms-fg);
|
|
229
|
+
font-size: 0.875rem;
|
|
230
|
+
|
|
231
|
+
&::placeholder {
|
|
232
|
+
color: var(--cms-muted);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
.sidebar-items {
|
|
237
|
+
overflow-y: auto;
|
|
238
|
+
padding: 0 0 1rem;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
.status {
|
|
242
|
+
color: var(--cms-muted);
|
|
243
|
+
font-size: 0.875rem;
|
|
244
|
+
padding: 0.5rem 0.75rem;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
.status--error {
|
|
248
|
+
color: var(--light-red);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
.sidebar-list {
|
|
252
|
+
display: grid;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
.sidebar-link {
|
|
256
|
+
display: block;
|
|
257
|
+
padding: 0.5rem 1rem;
|
|
258
|
+
color: var(--cms-fg);
|
|
259
|
+
text-decoration: none;
|
|
260
|
+
font-size: 1rem;
|
|
261
|
+
/* Override global link box-shadow underline — sidebar items use background highlight instead */
|
|
262
|
+
box-shadow: none;
|
|
263
|
+
|
|
264
|
+
&:hover {
|
|
265
|
+
background: var(--cms-border);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/*
|
|
269
|
+
* Active highlight extends to sidebar edges with no border-radius.
|
|
270
|
+
* Text is always white — --plum lacks sufficient contrast with
|
|
271
|
+
* both --cms-fg values (light-on-pink and dark-on-pink both fail WCAG AA).
|
|
272
|
+
*/
|
|
273
|
+
&[aria-current='page'] {
|
|
274
|
+
background: var(--plum);
|
|
275
|
+
color: #fff;
|
|
276
|
+
|
|
277
|
+
.item-subtitle {
|
|
278
|
+
color: #fff;
|
|
279
|
+
opacity: 0.75;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
.item-subtitle {
|
|
285
|
+
display: block;
|
|
286
|
+
font-size: 0.75rem;
|
|
287
|
+
color: var(--cms-muted);
|
|
288
|
+
margin-top: 0.25rem;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
.sidebar-heading-row {
|
|
292
|
+
display: grid;
|
|
293
|
+
grid-template-columns: 1fr auto;
|
|
294
|
+
align-items: center;
|
|
295
|
+
margin-bottom: 0.75rem;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
.add-btn .icon {
|
|
299
|
+
font-size: 1rem;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/* Flex is appropriate here because chips need inline flow with wrapping */
|
|
303
|
+
.item-label-row {
|
|
304
|
+
display: flex;
|
|
305
|
+
align-items: center;
|
|
306
|
+
gap: 0.25rem;
|
|
307
|
+
flex-wrap: wrap;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
.item-label-text {
|
|
311
|
+
/* Prevent long titles from pushing chips to a new line unnecessarily */
|
|
312
|
+
min-width: 0;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
.sidebar-footer {
|
|
316
|
+
display: grid;
|
|
317
|
+
grid-template-columns: 1fr auto;
|
|
318
|
+
align-items: center;
|
|
319
|
+
border-top: 1px solid var(--cms-border);
|
|
320
|
+
padding: 0.75rem 1rem;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/* Flex is appropriate here for inline icon + text alignment */
|
|
324
|
+
.logout-btn {
|
|
325
|
+
background: none;
|
|
326
|
+
border: none;
|
|
327
|
+
color: var(--cms-muted);
|
|
328
|
+
cursor: pointer;
|
|
329
|
+
display: flex;
|
|
330
|
+
align-items: center;
|
|
331
|
+
gap: 0.5rem;
|
|
332
|
+
font-size: 0.875rem;
|
|
333
|
+
padding: 0.25rem 0;
|
|
334
|
+
|
|
335
|
+
&:hover {
|
|
336
|
+
color: var(--cms-fg);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
</style>
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import {
|
|
3
|
+
type SortMode,
|
|
4
|
+
SORT_MODES,
|
|
5
|
+
SORT_ORDER,
|
|
6
|
+
writeSortMode,
|
|
7
|
+
} from '../../js/utils/sort';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Sort control for the admin sidebar: shows the active sort mode as an icon button and a popover with the remaining options. Extracted to keep AdminSidebar under the 350-line limit.
|
|
11
|
+
*/
|
|
12
|
+
interface Props {
|
|
13
|
+
// Bindable sort mode — updated when the user selects a new option
|
|
14
|
+
sortMode: SortMode;
|
|
15
|
+
// Collection name for localStorage persistence (constructs key: cms-sort-{storageKey})
|
|
16
|
+
storageKey?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
let { sortMode = $bindable(), storageKey }: Props = $props();
|
|
20
|
+
|
|
21
|
+
// Sort options available in the popover (all modes except the active one)
|
|
22
|
+
const popoverOptions = $derived(
|
|
23
|
+
SORT_ORDER.filter((mode) => mode !== sortMode),
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
// Unique ID for the sort popover element
|
|
27
|
+
const popoverId = $derived(`sort-popover-${storageKey ?? 'default'}`);
|
|
28
|
+
|
|
29
|
+
// Bound reference to the popover element for imperative hidePopover() calls
|
|
30
|
+
let popoverEl = $state<HTMLDivElement | null>(null);
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Applies the selected sort mode and persists it to localStorage.
|
|
34
|
+
* @param {SortMode} mode - The sort mode to apply
|
|
35
|
+
* @return {void}
|
|
36
|
+
*/
|
|
37
|
+
function selectSort(mode: SortMode): void {
|
|
38
|
+
sortMode = mode;
|
|
39
|
+
if (storageKey) {
|
|
40
|
+
writeSortMode(storageKey, mode);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
</script>
|
|
44
|
+
|
|
45
|
+
<button
|
|
46
|
+
class="icon-btn sort-btn"
|
|
47
|
+
title={SORT_MODES[sortMode].label}
|
|
48
|
+
commandfor={popoverId}
|
|
49
|
+
command="toggle-popover"
|
|
50
|
+
>
|
|
51
|
+
<span class="icon">
|
|
52
|
+
{SORT_MODES[sortMode].icon}
|
|
53
|
+
</span>
|
|
54
|
+
</button>
|
|
55
|
+
|
|
56
|
+
<div id={popoverId} class="sort-popover" popover="hint" bind:this={popoverEl}>
|
|
57
|
+
{#each popoverOptions as mode}
|
|
58
|
+
<button
|
|
59
|
+
class="sort-option"
|
|
60
|
+
onclick={() => {
|
|
61
|
+
selectSort(mode);
|
|
62
|
+
popoverEl?.hidePopover();
|
|
63
|
+
}}
|
|
64
|
+
>
|
|
65
|
+
<span class="icon">
|
|
66
|
+
{SORT_MODES[mode].icon}
|
|
67
|
+
</span>
|
|
68
|
+
{SORT_MODES[mode].label}
|
|
69
|
+
</button>
|
|
70
|
+
{/each}
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
<style>
|
|
74
|
+
/* Shared icon size for sort button and popover options */
|
|
75
|
+
.icon {
|
|
76
|
+
font-size: 1.25rem;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.sort-btn {
|
|
80
|
+
anchor-name: --sort-btn;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/* display: grid is in :popover-open to avoid overriding the UA's display: none on hidden popovers */
|
|
84
|
+
.sort-popover {
|
|
85
|
+
position-anchor: --sort-btn;
|
|
86
|
+
position: fixed;
|
|
87
|
+
inset: unset;
|
|
88
|
+
top: anchor(bottom);
|
|
89
|
+
right: anchor(right);
|
|
90
|
+
margin-top: 0.25rem;
|
|
91
|
+
background: var(--cms-border);
|
|
92
|
+
border: 1px solid var(--cms-muted);
|
|
93
|
+
border-radius: 0.25rem;
|
|
94
|
+
padding: 0.25rem;
|
|
95
|
+
/* Prevent width from changing when popover content changes */
|
|
96
|
+
min-width: 10rem;
|
|
97
|
+
|
|
98
|
+
&:popover-open {
|
|
99
|
+
display: grid;
|
|
100
|
+
gap: 0.25rem;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.sort-option {
|
|
105
|
+
display: grid;
|
|
106
|
+
grid-template-columns: auto 1fr;
|
|
107
|
+
align-items: center;
|
|
108
|
+
gap: 0.5rem;
|
|
109
|
+
padding: 0.5rem;
|
|
110
|
+
border-radius: 0.25rem;
|
|
111
|
+
cursor: pointer;
|
|
112
|
+
font-size: 0.875rem;
|
|
113
|
+
color: var(--cms-fg);
|
|
114
|
+
background: none;
|
|
115
|
+
border: none;
|
|
116
|
+
white-space: nowrap;
|
|
117
|
+
|
|
118
|
+
&:hover {
|
|
119
|
+
background: var(--cms-muted);
|
|
120
|
+
color: var(--cms-bg);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
</style>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Accessibility utilities.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/* Visually hidden but remains in the DOM for screen readers. */
|
|
6
|
+
.sr-only {
|
|
7
|
+
position: absolute;
|
|
8
|
+
width: 1px;
|
|
9
|
+
height: 1px;
|
|
10
|
+
overflow: hidden;
|
|
11
|
+
clip: rect(0, 0, 0, 0);
|
|
12
|
+
white-space: nowrap;
|
|
13
|
+
border: 0;
|
|
14
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Buttons
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/* Base button reset and shared properties */
|
|
6
|
+
|
|
7
|
+
.btn {
|
|
8
|
+
border: 1px solid transparent;
|
|
9
|
+
border-radius: 0.25rem;
|
|
10
|
+
color: var(--cms-fg);
|
|
11
|
+
cursor: pointer;
|
|
12
|
+
font-size: 0.875rem;
|
|
13
|
+
padding: 0.5rem 1rem;
|
|
14
|
+
text-align: center;
|
|
15
|
+
|
|
16
|
+
&:disabled {
|
|
17
|
+
opacity: 0.5;
|
|
18
|
+
cursor: default;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/* Gradient primary action (publish, confirm, connect) */
|
|
23
|
+
|
|
24
|
+
.btn--primary {
|
|
25
|
+
background: var(--button-bg, var(--plum));
|
|
26
|
+
color: var(--button-color, var(--cms-fg));
|
|
27
|
+
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
|
28
|
+
|
|
29
|
+
&:hover:not(:disabled) {
|
|
30
|
+
background: var(--button-hover-bg, var(--light-plum));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/* Large primary variant for full-width or hero actions (BackendPicker) */
|
|
35
|
+
|
|
36
|
+
.btn--primary-lg {
|
|
37
|
+
border-radius: 0.5rem;
|
|
38
|
+
font-size: 1rem;
|
|
39
|
+
padding: 0.75rem 1.5rem;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/* Neutral cancel / dismiss */
|
|
43
|
+
|
|
44
|
+
.btn--cancel {
|
|
45
|
+
background: var(--cms-border);
|
|
46
|
+
color: var(--cms-fg);
|
|
47
|
+
|
|
48
|
+
&:hover {
|
|
49
|
+
background: var(--cms-muted);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/* Solid danger (delete draft in dialogs) */
|
|
54
|
+
|
|
55
|
+
.btn--danger {
|
|
56
|
+
background: var(--light-red);
|
|
57
|
+
color: var(--cms-bg);
|
|
58
|
+
|
|
59
|
+
&:hover {
|
|
60
|
+
background: var(--red);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/* Outlined danger (delete draft in toolbar) */
|
|
65
|
+
|
|
66
|
+
.btn--danger-outline {
|
|
67
|
+
background: none;
|
|
68
|
+
border-color: var(--light-red);
|
|
69
|
+
color: var(--light-red);
|
|
70
|
+
|
|
71
|
+
&:hover {
|
|
72
|
+
background: var(--light-red);
|
|
73
|
+
color: var(--cms-bg);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/* Compact variant for tight layouts like the editor toolbar */
|
|
78
|
+
|
|
79
|
+
.btn--compact {
|
|
80
|
+
padding: 0.25rem 0.75rem;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/*
|
|
84
|
+
* Icon-only button for small icon triggers (add, sort, theme, info).
|
|
85
|
+
* Standalone class — not combined with .btn.
|
|
86
|
+
*/
|
|
87
|
+
|
|
88
|
+
.icon-btn {
|
|
89
|
+
background: none;
|
|
90
|
+
border: none;
|
|
91
|
+
color: var(--cms-muted);
|
|
92
|
+
padding: 0;
|
|
93
|
+
cursor: pointer;
|
|
94
|
+
display: grid;
|
|
95
|
+
place-items: center;
|
|
96
|
+
|
|
97
|
+
&:hover {
|
|
98
|
+
color: var(--cms-fg);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/* Outlined teal (save draft in toolbar) */
|
|
103
|
+
|
|
104
|
+
.btn--save-outline {
|
|
105
|
+
background: none;
|
|
106
|
+
border-color: var(--light-teal);
|
|
107
|
+
color: var(--light-teal);
|
|
108
|
+
|
|
109
|
+
&:hover:not(:disabled) {
|
|
110
|
+
background: var(--light-teal);
|
|
111
|
+
color: var(--cms-bg);
|
|
112
|
+
}
|
|
113
|
+
}
|