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.
Files changed (241) hide show
  1. package/.claude/settings.local.json +42 -0
  2. package/.github/workflows/ci.yml +31 -0
  3. package/.mcp.json +12 -0
  4. package/.prettierignore +5 -0
  5. package/.prettierrc.cjs +22 -0
  6. package/AGENTS.md +183 -0
  7. package/LICENSE +201 -0
  8. package/README.md +128 -0
  9. package/package.json +74 -0
  10. package/playground/.claude/settings.local.json +5 -0
  11. package/playground/astro.config.mjs +7 -0
  12. package/playground/node_modules/.bin/astro +21 -0
  13. package/playground/node_modules/.bin/rollup +21 -0
  14. package/playground/node_modules/.bin/tsc +21 -0
  15. package/playground/node_modules/.bin/tsserver +21 -0
  16. package/playground/node_modules/.bin/vite +21 -0
  17. package/playground/node_modules/.vite/_svelte_metadata.json +1 -0
  18. package/playground/node_modules/.vite/deps/@astrojs_svelte_client__js.js +80 -0
  19. package/playground/node_modules/.vite/deps/@astrojs_svelte_client__js.js.map +7 -0
  20. package/playground/node_modules/.vite/deps/_metadata.json +184 -0
  21. package/playground/node_modules/.vite/deps/astro___aria-query.js +6776 -0
  22. package/playground/node_modules/.vite/deps/astro___aria-query.js.map +7 -0
  23. package/playground/node_modules/.vite/deps/astro___axobject-query.js +3754 -0
  24. package/playground/node_modules/.vite/deps/astro___axobject-query.js.map +7 -0
  25. package/playground/node_modules/.vite/deps/astro___html-escaper.js +34 -0
  26. package/playground/node_modules/.vite/deps/astro___html-escaper.js.map +7 -0
  27. package/playground/node_modules/.vite/deps/chunk-AJXJMYAF.js +0 -0
  28. package/playground/node_modules/.vite/deps/chunk-AJXJMYAF.js.map +7 -0
  29. package/playground/node_modules/.vite/deps/chunk-ALJIOON6.js +1005 -0
  30. package/playground/node_modules/.vite/deps/chunk-ALJIOON6.js.map +7 -0
  31. package/playground/node_modules/.vite/deps/chunk-BUSYA2B4.js +8 -0
  32. package/playground/node_modules/.vite/deps/chunk-BUSYA2B4.js.map +7 -0
  33. package/playground/node_modules/.vite/deps/chunk-CNYJBM5F.js +21 -0
  34. package/playground/node_modules/.vite/deps/chunk-CNYJBM5F.js.map +7 -0
  35. package/playground/node_modules/.vite/deps/chunk-DBPNBGEI.js +223 -0
  36. package/playground/node_modules/.vite/deps/chunk-DBPNBGEI.js.map +7 -0
  37. package/playground/node_modules/.vite/deps/chunk-G3C2FXJT.js +204 -0
  38. package/playground/node_modules/.vite/deps/chunk-G3C2FXJT.js.map +7 -0
  39. package/playground/node_modules/.vite/deps/chunk-GKDKFWC5.js +27 -0
  40. package/playground/node_modules/.vite/deps/chunk-GKDKFWC5.js.map +7 -0
  41. package/playground/node_modules/.vite/deps/chunk-HNCLEOC5.js +4376 -0
  42. package/playground/node_modules/.vite/deps/chunk-HNCLEOC5.js.map +7 -0
  43. package/playground/node_modules/.vite/deps/chunk-JICYXBFU.js +688 -0
  44. package/playground/node_modules/.vite/deps/chunk-JICYXBFU.js.map +7 -0
  45. package/playground/node_modules/.vite/deps/chunk-KCUTL6DD.js +5099 -0
  46. package/playground/node_modules/.vite/deps/chunk-KCUTL6DD.js.map +7 -0
  47. package/playground/node_modules/.vite/deps/chunk-ZP4UNCSN.js +23 -0
  48. package/playground/node_modules/.vite/deps/chunk-ZP4UNCSN.js.map +7 -0
  49. package/playground/node_modules/.vite/deps/chunk-ZREFNRZZ.js +148 -0
  50. package/playground/node_modules/.vite/deps/chunk-ZREFNRZZ.js.map +7 -0
  51. package/playground/node_modules/.vite/deps/package.json +3 -0
  52. package/playground/node_modules/.vite/deps/smol-toml.js +843 -0
  53. package/playground/node_modules/.vite/deps/smol-toml.js.map +7 -0
  54. package/playground/node_modules/.vite/deps/svelte.js +55 -0
  55. package/playground/node_modules/.vite/deps/svelte.js.map +7 -0
  56. package/playground/node_modules/.vite/deps/svelte___clsx.js +9 -0
  57. package/playground/node_modules/.vite/deps/svelte___clsx.js.map +7 -0
  58. package/playground/node_modules/.vite/deps/svelte_animate.js +57 -0
  59. package/playground/node_modules/.vite/deps/svelte_animate.js.map +7 -0
  60. package/playground/node_modules/.vite/deps/svelte_attachments.js +15 -0
  61. package/playground/node_modules/.vite/deps/svelte_attachments.js.map +7 -0
  62. package/playground/node_modules/.vite/deps/svelte_easing.js +67 -0
  63. package/playground/node_modules/.vite/deps/svelte_easing.js.map +7 -0
  64. package/playground/node_modules/.vite/deps/svelte_events.js +11 -0
  65. package/playground/node_modules/.vite/deps/svelte_events.js.map +7 -0
  66. package/playground/node_modules/.vite/deps/svelte_internal.js +5 -0
  67. package/playground/node_modules/.vite/deps/svelte_internal.js.map +7 -0
  68. package/playground/node_modules/.vite/deps/svelte_internal_client.js +402 -0
  69. package/playground/node_modules/.vite/deps/svelte_internal_client.js.map +7 -0
  70. package/playground/node_modules/.vite/deps/svelte_internal_disclose-version.js +10 -0
  71. package/playground/node_modules/.vite/deps/svelte_internal_disclose-version.js.map +7 -0
  72. package/playground/node_modules/.vite/deps/svelte_internal_flags_async.js +8 -0
  73. package/playground/node_modules/.vite/deps/svelte_internal_flags_async.js.map +7 -0
  74. package/playground/node_modules/.vite/deps/svelte_internal_flags_legacy.js +8 -0
  75. package/playground/node_modules/.vite/deps/svelte_internal_flags_legacy.js.map +7 -0
  76. package/playground/node_modules/.vite/deps/svelte_internal_flags_tracing.js +8 -0
  77. package/playground/node_modules/.vite/deps/svelte_internal_flags_tracing.js.map +7 -0
  78. package/playground/node_modules/.vite/deps/svelte_legacy.js +35 -0
  79. package/playground/node_modules/.vite/deps/svelte_legacy.js.map +7 -0
  80. package/playground/node_modules/.vite/deps/svelte_motion.js +545 -0
  81. package/playground/node_modules/.vite/deps/svelte_motion.js.map +7 -0
  82. package/playground/node_modules/.vite/deps/svelte_reactivity.js +29 -0
  83. package/playground/node_modules/.vite/deps/svelte_reactivity.js.map +7 -0
  84. package/playground/node_modules/.vite/deps/svelte_reactivity_window.js +127 -0
  85. package/playground/node_modules/.vite/deps/svelte_reactivity_window.js.map +7 -0
  86. package/playground/node_modules/.vite/deps/svelte_store.js +103 -0
  87. package/playground/node_modules/.vite/deps/svelte_store.js.map +7 -0
  88. package/playground/node_modules/.vite/deps/svelte_transition.js +208 -0
  89. package/playground/node_modules/.vite/deps/svelte_transition.js.map +7 -0
  90. package/playground/package.json +16 -0
  91. package/playground/pnpm-lock.yaml +3167 -0
  92. package/playground/src/content/authors/jane-doe.json +8 -0
  93. package/playground/src/content/config/build.toml +2 -0
  94. package/playground/src/content/courses/web-fundamentals.json +29 -0
  95. package/playground/src/content/docs/advanced.mdx +6 -0
  96. package/playground/src/content/docs/intro.md +6 -0
  97. package/playground/src/content/guides/getting-started.mdx +6 -0
  98. package/playground/src/content/posts/hello-world.md +7 -0
  99. package/playground/src/content/products/t-shirt.json +16 -0
  100. package/playground/src/content/recipes/pancakes.mdoc +8 -0
  101. package/playground/src/content/settings/site.yml +2 -0
  102. package/playground/src/content.config.ts +198 -0
  103. package/playground/src/env.d.ts +1 -0
  104. package/playground/src/pages/index.astro +11 -0
  105. package/playground/src/pages/nebula.astro +14 -0
  106. package/pnpm-workspace.yaml +2 -0
  107. package/scripts/subset-icons.mjs +178 -0
  108. package/src/astro/index.ts +295 -0
  109. package/src/client/Admin.svelte +283 -0
  110. package/src/client/components/BackendPicker.svelte +291 -0
  111. package/src/client/components/DraftChip.svelte +46 -0
  112. package/src/client/components/MetadataForm.svelte +56 -0
  113. package/src/client/components/ThemeToggle.svelte +18 -0
  114. package/src/client/components/dialogs/DeleteDraftDialog.svelte +51 -0
  115. package/src/client/components/dialogs/FilenameDialog.svelte +129 -0
  116. package/src/client/components/editor/EditorPane.svelte +227 -0
  117. package/src/client/components/editor/EditorTabs.svelte +81 -0
  118. package/src/client/components/editor/EditorToolbar.svelte +131 -0
  119. package/src/client/components/editor/FormatSelector.svelte +66 -0
  120. package/src/client/components/editor/Toolbar.svelte +17 -0
  121. package/src/client/components/fields/ArrayField.svelte +339 -0
  122. package/src/client/components/fields/ArrayItem.svelte +325 -0
  123. package/src/client/components/fields/BooleanField.svelte +114 -0
  124. package/src/client/components/fields/DateField.svelte +82 -0
  125. package/src/client/components/fields/EnumField.svelte +74 -0
  126. package/src/client/components/fields/FieldWrapper.svelte +96 -0
  127. package/src/client/components/fields/NumberField.svelte +99 -0
  128. package/src/client/components/fields/ObjectField.svelte +121 -0
  129. package/src/client/components/fields/SchemaField.svelte +107 -0
  130. package/src/client/components/fields/StringField.svelte +104 -0
  131. package/src/client/components/sidebar/AdminSidebar.svelte +339 -0
  132. package/src/client/components/sidebar/AdminSidebarSort.svelte +123 -0
  133. package/src/client/css/a11y.css +14 -0
  134. package/src/client/css/btn.css +113 -0
  135. package/src/client/css/dialog.css +29 -0
  136. package/src/client/css/field-input.css +39 -0
  137. package/src/client/css/reset.css +59 -0
  138. package/src/client/css/theme.css +77 -0
  139. package/src/client/index.ts +1 -0
  140. package/src/client/js/drafts/merge.svelte.ts +121 -0
  141. package/src/client/js/drafts/ops.svelte.ts +227 -0
  142. package/src/client/js/drafts/storage.ts +108 -0
  143. package/src/client/js/drafts/workers/diff.ts +40 -0
  144. package/src/client/js/editor/editor.svelte.ts +343 -0
  145. package/src/client/js/editor/languages.ts +98 -0
  146. package/src/client/js/editor/link-wrap.ts +45 -0
  147. package/src/client/js/editor/markdown-shortcuts.ts +261 -0
  148. package/src/client/js/handlers/admin.ts +246 -0
  149. package/src/client/js/state/dialogs.svelte.ts +35 -0
  150. package/src/client/js/state/router.svelte.ts +156 -0
  151. package/src/client/js/state/schema.svelte.ts +140 -0
  152. package/src/client/js/state/state.svelte.ts +334 -0
  153. package/src/client/js/state/theme.svelte.ts +173 -0
  154. package/src/client/js/storage/adapter.ts +102 -0
  155. package/src/client/js/storage/client.ts +150 -0
  156. package/src/client/js/storage/db.ts +36 -0
  157. package/src/client/js/storage/fsa.ts +110 -0
  158. package/src/client/js/storage/github.ts +297 -0
  159. package/src/client/js/storage/storage.ts +83 -0
  160. package/src/client/js/storage/workers/frontmatter.ts +320 -0
  161. package/src/client/js/storage/workers/storage.ts +177 -0
  162. package/src/client/js/storage/workers/toml-parser.ts +106 -0
  163. package/src/client/js/storage/workers/yaml-parser.ts +132 -0
  164. package/src/client/js/utils/file-types.ts +192 -0
  165. package/src/client/js/utils/format.ts +16 -0
  166. package/src/client/js/utils/frontmatter.ts +38 -0
  167. package/src/client/js/utils/schema-utils.ts +295 -0
  168. package/src/client/js/utils/slug.ts +18 -0
  169. package/src/client/js/utils/sort.ts +84 -0
  170. package/src/client/js/utils/stable-stringify.ts +27 -0
  171. package/src/client/js/utils/url-utils.ts +38 -0
  172. package/src/types.ts +25 -0
  173. package/src/virtual.d.ts +22 -0
  174. package/svelte.config.js +4 -0
  175. package/tests/astro/build.test.ts +63 -0
  176. package/tests/astro/index.test.ts +689 -0
  177. package/tests/client/components/Admin.test.ts +446 -0
  178. package/tests/client/components/BackendPicker.test.ts +239 -0
  179. package/tests/client/components/DraftChip.test.ts +53 -0
  180. package/tests/client/components/MetadataForm.test.ts +164 -0
  181. package/tests/client/components/dialogs/DeleteDraftDialog.test.ts +91 -0
  182. package/tests/client/components/dialogs/FilenameDialog.test.ts +209 -0
  183. package/tests/client/components/dialogs/dialog-stubs.ts +19 -0
  184. package/tests/client/components/editor/EditorPane.test.ts +100 -0
  185. package/tests/client/components/editor/EditorTabs.test.ts +253 -0
  186. package/tests/client/components/editor/EditorToolbar.test.ts +252 -0
  187. package/tests/client/components/editor/fixtures.ts +31 -0
  188. package/tests/client/components/fields/ArrayField.test.ts +197 -0
  189. package/tests/client/components/fields/BooleanField.test.ts +206 -0
  190. package/tests/client/components/fields/DateField.test.ts +210 -0
  191. package/tests/client/components/fields/EnumField.test.ts +246 -0
  192. package/tests/client/components/fields/NumberField.test.ts +240 -0
  193. package/tests/client/components/fields/ObjectField.test.ts +157 -0
  194. package/tests/client/components/fields/SchemaField.test.ts +190 -0
  195. package/tests/client/components/fields/StringField.test.ts +223 -0
  196. package/tests/client/components/sidebar/AdminSidebar.test.ts +285 -0
  197. package/tests/client/components/sidebar/AdminSidebarSort.test.ts +135 -0
  198. package/tests/client/components/sidebar/sort-mock.ts +23 -0
  199. package/tests/client/js/drafts/fixtures.ts +22 -0
  200. package/tests/client/js/drafts/merge.test.ts +282 -0
  201. package/tests/client/js/drafts/ops.test.ts +658 -0
  202. package/tests/client/js/drafts/storage.test.ts +200 -0
  203. package/tests/client/js/drafts/workers/diff.test.ts +165 -0
  204. package/tests/client/js/editor/editor.test.ts +616 -0
  205. package/tests/client/js/editor/link-wrap.test.ts +225 -0
  206. package/tests/client/js/editor/markdown-shortcuts.test.ts +370 -0
  207. package/tests/client/js/handlers/admin.test.ts +467 -0
  208. package/tests/client/js/state/router.test.ts +619 -0
  209. package/tests/client/js/state/schema.test.ts +266 -0
  210. package/tests/client/js/state/state.test.ts +328 -0
  211. package/tests/client/js/storage/adapter.test.ts +115 -0
  212. package/tests/client/js/storage/client.test.ts +250 -0
  213. package/tests/client/js/storage/db.test.ts +59 -0
  214. package/tests/client/js/storage/fsa.test.ts +284 -0
  215. package/tests/client/js/storage/github.test.ts +349 -0
  216. package/tests/client/js/storage/mock-port.ts +95 -0
  217. package/tests/client/js/storage/storage.test.ts +77 -0
  218. package/tests/client/js/storage/workers/frontmatter.test.ts +479 -0
  219. package/tests/client/js/storage/workers/storage.test.ts +299 -0
  220. package/tests/client/js/storage/workers/toml-parser.test.ts +169 -0
  221. package/tests/client/js/storage/workers/yaml-parser.test.ts +168 -0
  222. package/tests/client/js/utils/file-types.test.ts +268 -0
  223. package/tests/client/js/utils/frontmatter.test.ts +87 -0
  224. package/tests/client/js/utils/schema-utils.test.ts +318 -0
  225. package/tests/client/js/utils/slug.test.ts +58 -0
  226. package/tests/client/js/utils/sort.test.ts +276 -0
  227. package/tests/client/js/utils/stable-stringify.test.ts +68 -0
  228. package/tests/client/js/utils/url-utils.test.ts +70 -0
  229. package/tests/e2e/backend-connection.test.ts +301 -0
  230. package/tests/e2e/draft-lifecycle.test.ts +388 -0
  231. package/tests/e2e/editing.test.ts +355 -0
  232. package/tests/e2e/github-adapter.test.ts +330 -0
  233. package/tests/e2e/helpers/mock-adapter.ts +166 -0
  234. package/tests/e2e/helpers/test-app.ts +155 -0
  235. package/tests/e2e/navigation.test.ts +358 -0
  236. package/tests/e2e/publishing.test.ts +345 -0
  237. package/tests/e2e/unsaved-changes.test.ts +317 -0
  238. package/tests/setup.ts +2 -0
  239. package/tests/stubs/codemirror.ts +197 -0
  240. package/tsconfig.json +19 -0
  241. 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
+ }