@quoin-cms/admin 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 (110) hide show
  1. package/LICENSE +661 -0
  2. package/biome.json +62 -0
  3. package/dist/assets/index-C9Y5-AKj.js +33 -0
  4. package/dist/assets/index-uVdiUjty.css +1 -0
  5. package/dist/index.html +20 -0
  6. package/index.html +19 -0
  7. package/package.json +43 -0
  8. package/src/AdminRoot.svelte +98 -0
  9. package/src/app.css +211 -0
  10. package/src/lib/Slot.svelte +65 -0
  11. package/src/lib/api/auth.ts +26 -0
  12. package/src/lib/api/client.ts +73 -0
  13. package/src/lib/api/files.ts +56 -0
  14. package/src/lib/api/globals.ts +13 -0
  15. package/src/lib/api/records.ts +102 -0
  16. package/src/lib/api/schema.ts +7 -0
  17. package/src/lib/api/versions.ts +40 -0
  18. package/src/lib/components/AdminHeader.svelte +107 -0
  19. package/src/lib/components/AdminSidebar.svelte +262 -0
  20. package/src/lib/components/DeleteDialog.svelte +58 -0
  21. package/src/lib/components/DocumentEditLayout.svelte +263 -0
  22. package/src/lib/components/DynamicForm.svelte +74 -0
  23. package/src/lib/components/KpiCard.svelte +75 -0
  24. package/src/lib/components/MediaLibrary.svelte +311 -0
  25. package/src/lib/components/Pagination.svelte +78 -0
  26. package/src/lib/components/RangeFilter.svelte +41 -0
  27. package/src/lib/components/RecordGrid.svelte +123 -0
  28. package/src/lib/components/RecordTable.svelte +156 -0
  29. package/src/lib/components/cells/CheckboxCell.svelte +10 -0
  30. package/src/lib/components/cells/ColorCell.svelte +15 -0
  31. package/src/lib/components/cells/DateCell.svelte +8 -0
  32. package/src/lib/components/cells/RelationshipCell.svelte +20 -0
  33. package/src/lib/components/cells/RichTextCell.svelte +21 -0
  34. package/src/lib/components/cells/SelectCell.svelte +26 -0
  35. package/src/lib/components/cells/TextCell.svelte +8 -0
  36. package/src/lib/components/cells/UploadCell.svelte +34 -0
  37. package/src/lib/components/cells/index.ts +28 -0
  38. package/src/lib/components/charts/TimeSeriesChart.svelte +184 -0
  39. package/src/lib/components/doc/ApiView.svelte +181 -0
  40. package/src/lib/components/doc/Autosave.svelte +102 -0
  41. package/src/lib/components/doc/DocHeader.svelte +86 -0
  42. package/src/lib/components/doc/DocMetaStrip.svelte +103 -0
  43. package/src/lib/components/doc/DocTabBar.svelte +26 -0
  44. package/src/lib/components/doc/HeaderModeSwitch.svelte +32 -0
  45. package/src/lib/components/doc/PublishButton.svelte +114 -0
  46. package/src/lib/components/doc/ScheduleModal.svelte +110 -0
  47. package/src/lib/components/doc/VersionHistory.svelte +20 -0
  48. package/src/lib/components/fields/ArrayFieldEditor.svelte +62 -0
  49. package/src/lib/components/fields/BlockCard.svelte +63 -0
  50. package/src/lib/components/fields/BlocksFieldEditor.svelte +83 -0
  51. package/src/lib/components/fields/CheckboxField.svelte +27 -0
  52. package/src/lib/components/fields/ColorField.svelte +46 -0
  53. package/src/lib/components/fields/DateField.svelte +52 -0
  54. package/src/lib/components/fields/EmailField.svelte +30 -0
  55. package/src/lib/components/fields/FileField.svelte +280 -0
  56. package/src/lib/components/fields/JsonField.svelte +145 -0
  57. package/src/lib/components/fields/NumberField.svelte +44 -0
  58. package/src/lib/components/fields/PasswordField.svelte +38 -0
  59. package/src/lib/components/fields/RelationshipField.svelte +271 -0
  60. package/src/lib/components/fields/RichTextField.svelte +139 -0
  61. package/src/lib/components/fields/SelectField.svelte +33 -0
  62. package/src/lib/components/fields/SlugField.svelte +70 -0
  63. package/src/lib/components/fields/TabsField.svelte +56 -0
  64. package/src/lib/components/fields/TagsField.svelte +85 -0
  65. package/src/lib/components/fields/TextField.svelte +36 -0
  66. package/src/lib/components/fields/TextareaField.svelte +32 -0
  67. package/src/lib/components/fields/UploadField.svelte +166 -0
  68. package/src/lib/components/fields/UploadFieldDispatch.svelte +21 -0
  69. package/src/lib/components/fields/UploadGalleryField.svelte +166 -0
  70. package/src/lib/components/fields/index.ts +22 -0
  71. package/src/lib/components/fields/registry.ts +58 -0
  72. package/src/lib/components/lexical/CustomHTMLComponent.svelte +52 -0
  73. package/src/lib/components/lexical/CustomHTMLNode.ts +94 -0
  74. package/src/lib/components/lexical/PullQuoteComponent.svelte +73 -0
  75. package/src/lib/components/lexical/PullQuoteNode.ts +112 -0
  76. package/src/lib/components/lexical/lexical-helpers.ts +24 -0
  77. package/src/lib/components/lexical/nodes.ts +8 -0
  78. package/src/lib/components/lexical/toolbar/EditorToolbar.svelte +159 -0
  79. package/src/lib/components/lexical/toolbar/InsertBlockDropdown.svelte +278 -0
  80. package/src/lib/components/versions/CompareSelector.svelte +31 -0
  81. package/src/lib/components/versions/FieldDiff.svelte +141 -0
  82. package/src/lib/components/versions/RestoreModal.svelte +67 -0
  83. package/src/lib/components/versions/StatusPill.svelte +21 -0
  84. package/src/lib/context.svelte.ts +156 -0
  85. package/src/lib/router/index.svelte.ts +282 -0
  86. package/src/lib/router/matcher.ts +52 -0
  87. package/src/lib/stores/branding.svelte.ts +74 -0
  88. package/src/lib/stores/schema.svelte.ts +17 -0
  89. package/src/lib/types/schema.ts +126 -0
  90. package/src/lib/utils/cn.ts +6 -0
  91. package/src/lib/utils/diff.ts +112 -0
  92. package/src/lib/utils/dirty.svelte.ts +50 -0
  93. package/src/lib/utils/format.ts +28 -0
  94. package/src/lib/utils/json-highlight.ts +34 -0
  95. package/src/lib/utils/slug.ts +8 -0
  96. package/src/main.ts +32 -0
  97. package/src/views/AdminLayout.svelte +73 -0
  98. package/src/views/AdsAnalyticsView.svelte +152 -0
  99. package/src/views/CollectionEditView.svelte +117 -0
  100. package/src/views/CollectionListView.svelte +347 -0
  101. package/src/views/CollectionNewView.svelte +68 -0
  102. package/src/views/CustomPageView.svelte +59 -0
  103. package/src/views/DashboardView.svelte +370 -0
  104. package/src/views/GlobalEditView.svelte +100 -0
  105. package/src/views/LoginView.svelte +231 -0
  106. package/src/views/NotFoundView.svelte +9 -0
  107. package/src/views/VersionDetailView.svelte +307 -0
  108. package/src/views/VersionsListView.svelte +201 -0
  109. package/tsconfig.json +25 -0
  110. package/vite.config.ts +80 -0
@@ -0,0 +1,112 @@
1
+ import { DecoratorNode } from 'lexical';
2
+ import type {
3
+ EditorConfig,
4
+ LexicalEditor,
5
+ LexicalNode,
6
+ NodeKey,
7
+ SerializedLexicalNode,
8
+ Spread,
9
+ } from 'lexical';
10
+ import PullQuoteComponent from './PullQuoteComponent.svelte';
11
+
12
+ export type SerializedPullQuoteNode = Spread<
13
+ {
14
+ type: 'pull-quote';
15
+ version: 1;
16
+ text: string;
17
+ attribution: string;
18
+ },
19
+ SerializedLexicalNode
20
+ >;
21
+
22
+ export class PullQuoteNode extends DecoratorNode<unknown> {
23
+ __text: string;
24
+ __attribution: string;
25
+
26
+ static getType(): string {
27
+ return 'pull-quote';
28
+ }
29
+
30
+ static clone(node: PullQuoteNode): PullQuoteNode {
31
+ return new PullQuoteNode(node.__text, node.__attribution, node.__key);
32
+ }
33
+
34
+ constructor(text: string, attribution: string, key?: NodeKey) {
35
+ super(key);
36
+ this.__text = text;
37
+ this.__attribution = attribution;
38
+ }
39
+
40
+ createDOM(_config: EditorConfig): HTMLElement {
41
+ const div = document.createElement('div');
42
+ div.className = 'lexical-pull-quote';
43
+ div.setAttribute('contenteditable', 'false');
44
+ return div;
45
+ }
46
+
47
+ updateDOM(): boolean {
48
+ return false;
49
+ }
50
+
51
+ static importJSON(json: SerializedPullQuoteNode): PullQuoteNode {
52
+ return new PullQuoteNode(json.text, json.attribution);
53
+ }
54
+
55
+ exportJSON(): SerializedPullQuoteNode {
56
+ return {
57
+ ...super.exportJSON(),
58
+ type: 'pull-quote',
59
+ version: 1,
60
+ text: this.__text,
61
+ attribution: this.__attribution,
62
+ };
63
+ }
64
+
65
+ decorate(_editor: LexicalEditor, _config: EditorConfig) {
66
+ return {
67
+ componentClass: PullQuoteComponent,
68
+ updateProps: (props: Record<string, unknown>) => {
69
+ props.text = this.__text;
70
+ props.attribution = this.__attribution;
71
+ props.nodeKey = this.__key;
72
+ },
73
+ };
74
+ }
75
+
76
+ isInline(): boolean {
77
+ return false;
78
+ }
79
+
80
+ getText(): string {
81
+ return this.__text;
82
+ }
83
+
84
+ getAttribution(): string {
85
+ return this.__attribution;
86
+ }
87
+
88
+ setText(text: string): this {
89
+ const writable = this.getWritable();
90
+ writable.__text = text;
91
+ return writable;
92
+ }
93
+
94
+ setAttribution(attribution: string): this {
95
+ const writable = this.getWritable();
96
+ writable.__attribution = attribution;
97
+ return writable;
98
+ }
99
+ }
100
+
101
+ export function $createPullQuoteNode(
102
+ text: string,
103
+ attribution: string,
104
+ ): PullQuoteNode {
105
+ return new PullQuoteNode(text, attribution);
106
+ }
107
+
108
+ export function $isPullQuoteNode(
109
+ node: LexicalNode | null | undefined,
110
+ ): node is PullQuoteNode {
111
+ return node instanceof PullQuoteNode;
112
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Re-exports of Lexical $ functions for use in Svelte 5 components.
3
+ * Svelte 5 reserves the $ prefix, so we alias these functions.
4
+ */
5
+ export {
6
+ $getNodeByKey as getNodeByKey,
7
+ $getSelection as getSelection,
8
+ $isRangeSelection as isRangeSelection,
9
+ $insertNodes as insertNodes,
10
+ $createParagraphNode as createParagraphNode,
11
+ } from 'lexical';
12
+
13
+ export { $isPullQuoteNode as isPullQuoteNode } from './PullQuoteNode.js';
14
+ export { $isCustomHTMLNode as isCustomHTMLNode } from './CustomHTMLNode.js';
15
+ export { $createPullQuoteNode as createPullQuoteNode } from './PullQuoteNode.js';
16
+ export { $createCustomHTMLNode as createCustomHTMLNode } from './CustomHTMLNode.js';
17
+
18
+ export { $createYouTubeNode as createYouTubeNode } from 'svelte-lexical';
19
+ export { $createImageNode as createImageNode } from 'svelte-lexical';
20
+
21
+ export { $isHeadingNode as isHeadingNode } from '@lexical/rich-text';
22
+ export { $isListNode as isListNode } from '@lexical/list';
23
+ export { $getNearestNodeOfType as getNearestNodeOfType } from '@lexical/utils';
24
+ export { ListNode } from '@lexical/list';
@@ -0,0 +1,8 @@
1
+ import type { Klass, LexicalNode } from 'lexical';
2
+ import { PullQuoteNode } from './PullQuoteNode.js';
3
+ import { CustomHTMLNode } from './CustomHTMLNode.js';
4
+
5
+ export const customNodes: Array<Klass<LexicalNode>> = [
6
+ PullQuoteNode,
7
+ CustomHTMLNode,
8
+ ];
@@ -0,0 +1,159 @@
1
+ <script lang="ts">
2
+ import {
3
+ getEditor,
4
+ toggleBold,
5
+ toggleItalic,
6
+ toggleUnderline,
7
+ toggleStrikethrough,
8
+ formatHeading,
9
+ formatBulletList,
10
+ formatNumberedList,
11
+ formatQuote,
12
+ formatCode,
13
+ undo,
14
+ redo,
15
+ } from 'svelte-lexical';
16
+ import {
17
+ COMMAND_PRIORITY_CRITICAL,
18
+ SELECTION_CHANGE_COMMAND,
19
+ } from 'lexical';
20
+ import { getSelection, isRangeSelection, isHeadingNode, isListNode, getNearestNodeOfType, ListNode } from '../lexical-helpers.js';
21
+ import { onMount } from 'svelte';
22
+ import {
23
+ Bold,
24
+ Italic,
25
+ Underline as UnderlineIcon,
26
+ Strikethrough,
27
+ Heading1,
28
+ Heading2,
29
+ Heading3,
30
+ List,
31
+ ListOrdered,
32
+ Quote,
33
+ Code,
34
+ Undo2,
35
+ Redo2,
36
+ } from 'lucide-svelte';
37
+ import InsertBlockDropdown from './InsertBlockDropdown.svelte';
38
+
39
+ const editor = getEditor();
40
+
41
+ // Active format states
42
+ let isBold = $state(false);
43
+ let isItalic = $state(false);
44
+ let isUnderline = $state(false);
45
+ let isStrike = $state(false);
46
+ let blockType = $state('paragraph');
47
+
48
+ function updateToolbar() {
49
+ const selection = getSelection();
50
+ if (isRangeSelection(selection)) {
51
+ isBold = selection.hasFormat('bold');
52
+ isItalic = selection.hasFormat('italic');
53
+ isUnderline = selection.hasFormat('underline');
54
+ isStrike = selection.hasFormat('strikethrough');
55
+
56
+ const anchorNode = selection.anchor.getNode();
57
+ const element = anchorNode.getKey() === 'root'
58
+ ? anchorNode
59
+ : anchorNode.getTopLevelElementOrThrow();
60
+
61
+ if (isHeadingNode(element)) {
62
+ blockType = element.getTag();
63
+ } else if (isListNode(element)) {
64
+ const parentList = getNearestNodeOfType(anchorNode, ListNode);
65
+ blockType = parentList ? parentList.getListType() : element.getListType();
66
+ } else {
67
+ blockType = element.getType();
68
+ }
69
+ }
70
+ }
71
+
72
+ onMount(() => {
73
+ const unregister = editor.registerCommand(
74
+ SELECTION_CHANGE_COMMAND,
75
+ () => {
76
+ updateToolbar();
77
+ return false;
78
+ },
79
+ COMMAND_PRIORITY_CRITICAL,
80
+ );
81
+
82
+ const unregisterUpdate = editor.registerUpdateListener(({ editorState }) => {
83
+ editorState.read(() => {
84
+ updateToolbar();
85
+ });
86
+ });
87
+
88
+ return () => {
89
+ unregister();
90
+ unregisterUpdate();
91
+ };
92
+ });
93
+
94
+ function btn(active: boolean): string {
95
+ return `rounded p-1.5 hover:bg-accent ${active ? 'bg-accent text-accent-foreground' : ''}`;
96
+ }
97
+ </script>
98
+
99
+ <div class="flex flex-wrap gap-0.5 border-b bg-muted/30 p-1.5">
100
+ <!-- Text formatting -->
101
+ <button type="button" class={btn(isBold)} onclick={() => toggleBold(editor)} title="Bold">
102
+ <Bold class="h-4 w-4" />
103
+ </button>
104
+ <button type="button" class={btn(isItalic)} onclick={() => toggleItalic(editor)} title="Italic">
105
+ <Italic class="h-4 w-4" />
106
+ </button>
107
+ <button type="button" class={btn(isUnderline)} onclick={() => toggleUnderline(editor)} title="Underline">
108
+ <UnderlineIcon class="h-4 w-4" />
109
+ </button>
110
+ <button type="button" class={btn(isStrike)} onclick={() => toggleStrikethrough(editor)} title="Strikethrough">
111
+ <Strikethrough class="h-4 w-4" />
112
+ </button>
113
+
114
+ <div class="mx-1 w-px bg-border"></div>
115
+
116
+ <!-- Headings -->
117
+ <button type="button" class={btn(blockType === 'h1')} onclick={() => formatHeading(editor, blockType, 'h1')} title="Heading 1">
118
+ <Heading1 class="h-4 w-4" />
119
+ </button>
120
+ <button type="button" class={btn(blockType === 'h2')} onclick={() => formatHeading(editor, blockType, 'h2')} title="Heading 2">
121
+ <Heading2 class="h-4 w-4" />
122
+ </button>
123
+ <button type="button" class={btn(blockType === 'h3')} onclick={() => formatHeading(editor, blockType, 'h3')} title="Heading 3">
124
+ <Heading3 class="h-4 w-4" />
125
+ </button>
126
+
127
+ <div class="mx-1 w-px bg-border"></div>
128
+
129
+ <!-- Lists -->
130
+ <button type="button" class={btn(blockType === 'bullet')} onclick={() => formatBulletList(editor, blockType)} title="Bullet List">
131
+ <List class="h-4 w-4" />
132
+ </button>
133
+ <button type="button" class={btn(blockType === 'number')} onclick={() => formatNumberedList(editor, blockType)} title="Ordered List">
134
+ <ListOrdered class="h-4 w-4" />
135
+ </button>
136
+
137
+ <!-- Block types -->
138
+ <button type="button" class={btn(blockType === 'quote')} onclick={() => formatQuote(editor, blockType)} title="Blockquote">
139
+ <Quote class="h-4 w-4" />
140
+ </button>
141
+ <button type="button" class={btn(blockType === 'code')} onclick={() => formatCode(editor, blockType)} title="Code Block">
142
+ <Code class="h-4 w-4" />
143
+ </button>
144
+
145
+ <div class="mx-1 w-px bg-border"></div>
146
+
147
+ <!-- Insert dropdown -->
148
+ <InsertBlockDropdown />
149
+
150
+ <div class="mx-1 w-px bg-border"></div>
151
+
152
+ <!-- History -->
153
+ <button type="button" class="rounded p-1.5 hover:bg-accent" onclick={() => undo(editor)} title="Undo">
154
+ <Undo2 class="h-4 w-4" />
155
+ </button>
156
+ <button type="button" class="rounded p-1.5 hover:bg-accent" onclick={() => redo(editor)} title="Redo">
157
+ <Redo2 class="h-4 w-4" />
158
+ </button>
159
+ </div>
@@ -0,0 +1,278 @@
1
+ <script lang="ts">
2
+ import { getEditor } from 'svelte-lexical';
3
+ import { insertNodes, createParagraphNode, createPullQuoteNode, createCustomHTMLNode, createYouTubeNode, createImageNode } from '../lexical-helpers.js';
4
+ import { Plus, Youtube, Quote, Code2, Image as ImageIcon } from 'lucide-svelte';
5
+ import MediaLibrary from '../../MediaLibrary.svelte';
6
+
7
+ const editor = getEditor();
8
+
9
+ let isOpen = $state(false);
10
+ let showYoutubeDialog = $state(false);
11
+ let showImageDialog = $state(false);
12
+ let mediaLibraryOpen = $state(false);
13
+ let youtubeUrl = $state('');
14
+ let imageUrl = $state('');
15
+ let imageAlt = $state('');
16
+
17
+ function toggleDropdown() {
18
+ isOpen = !isOpen;
19
+ }
20
+
21
+ function closeDropdown() {
22
+ isOpen = false;
23
+ }
24
+
25
+ function extractYoutubeId(url: string): string | null {
26
+ const patterns = [
27
+ /(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})/,
28
+ /^([a-zA-Z0-9_-]{11})$/,
29
+ ];
30
+ for (const pattern of patterns) {
31
+ const match = url.match(pattern);
32
+ if (match) return match[1];
33
+ }
34
+ return null;
35
+ }
36
+
37
+ function insertYoutube() {
38
+ const videoId = extractYoutubeId(youtubeUrl);
39
+ if (!videoId) {
40
+ alert('Invalid YouTube URL');
41
+ return;
42
+ }
43
+ editor.update(() => {
44
+ const node = createYouTubeNode(videoId);
45
+ insertNodes([node]);
46
+ });
47
+ youtubeUrl = '';
48
+ showYoutubeDialog = false;
49
+ closeDropdown();
50
+ }
51
+
52
+ function handleImageSelect(media: { url: string; alt: string }) {
53
+ editor.update(() => {
54
+ const node = createImageNode({
55
+ src: media.url,
56
+ altText: media.alt || '',
57
+ width: undefined,
58
+ height: undefined,
59
+ maxWidth: 800,
60
+ });
61
+ insertNodes([node]);
62
+ });
63
+ mediaLibraryOpen = false;
64
+ closeDropdown();
65
+ }
66
+
67
+ function insertImage() {
68
+ if (!imageUrl) {
69
+ alert('Please enter an image URL');
70
+ return;
71
+ }
72
+ editor.update(() => {
73
+ const node = createImageNode({
74
+ src: imageUrl,
75
+ altText: imageAlt || '',
76
+ width: undefined,
77
+ height: undefined,
78
+ maxWidth: 800,
79
+ });
80
+ insertNodes([node]);
81
+ });
82
+ imageUrl = '';
83
+ imageAlt = '';
84
+ showImageDialog = false;
85
+ closeDropdown();
86
+ }
87
+
88
+ function insertPullQuote() {
89
+ editor.update(() => {
90
+ const node = createPullQuoteNode('', '');
91
+ insertNodes([node]);
92
+ const paragraph = createParagraphNode();
93
+ node.insertAfter(paragraph);
94
+ });
95
+ closeDropdown();
96
+ }
97
+
98
+ function insertCustomHTML() {
99
+ editor.update(() => {
100
+ const node = createCustomHTMLNode('');
101
+ insertNodes([node]);
102
+ const paragraph = createParagraphNode();
103
+ node.insertAfter(paragraph);
104
+ });
105
+ closeDropdown();
106
+ }
107
+
108
+ function handleYoutubeKeydown(e: KeyboardEvent) {
109
+ if (e.key === 'Enter') {
110
+ e.preventDefault();
111
+ insertYoutube();
112
+ }
113
+ if (e.key === 'Escape') {
114
+ showYoutubeDialog = false;
115
+ }
116
+ }
117
+
118
+ function handleImageKeydown(e: KeyboardEvent) {
119
+ if (e.key === 'Enter') {
120
+ e.preventDefault();
121
+ insertImage();
122
+ }
123
+ if (e.key === 'Escape') {
124
+ showImageDialog = false;
125
+ }
126
+ }
127
+ </script>
128
+
129
+ <svelte:window onclick={() => { if (isOpen) closeDropdown(); }} />
130
+
131
+ <div class="relative">
132
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
133
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
134
+ <button
135
+ type="button"
136
+ class="rounded p-1.5 hover:bg-accent flex items-center gap-0.5"
137
+ onclick={(e) => { e.stopPropagation(); toggleDropdown(); }}
138
+ title="Insert block"
139
+ >
140
+ <Plus class="h-4 w-4" />
141
+ <span class="text-xs">Insert</span>
142
+ </button>
143
+
144
+ {#if isOpen}
145
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
146
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
147
+ <div
148
+ class="absolute left-0 top-full mt-1 z-50 min-w-[180px] rounded-md border bg-popover p-1 shadow-md"
149
+ onclick={(e) => e.stopPropagation()}
150
+ >
151
+ <button
152
+ type="button"
153
+ class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-accent"
154
+ onclick={() => { showYoutubeDialog = true; isOpen = false; }}
155
+ >
156
+ <Youtube class="h-4 w-4" />
157
+ YouTube Video
158
+ </button>
159
+ <button
160
+ type="button"
161
+ class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-accent"
162
+ onclick={() => { mediaLibraryOpen = true; isOpen = false; }}
163
+ >
164
+ <ImageIcon class="h-4 w-4" />
165
+ Image
166
+ </button>
167
+ <button
168
+ type="button"
169
+ class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-accent"
170
+ onclick={() => { showImageDialog = true; isOpen = false; }}
171
+ >
172
+ <ImageIcon class="h-4 w-4" />
173
+ Image from URL
174
+ </button>
175
+ <button
176
+ type="button"
177
+ class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-accent"
178
+ onclick={insertPullQuote}
179
+ >
180
+ <Quote class="h-4 w-4" />
181
+ Pull Quote
182
+ </button>
183
+ <button
184
+ type="button"
185
+ class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-accent"
186
+ onclick={insertCustomHTML}
187
+ >
188
+ <Code2 class="h-4 w-4" />
189
+ Custom HTML
190
+ </button>
191
+ </div>
192
+ {/if}
193
+ </div>
194
+
195
+ <!-- YouTube URL dialog -->
196
+ {#if showYoutubeDialog}
197
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
198
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
199
+ <div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onclick={() => (showYoutubeDialog = false)}>
200
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
201
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
202
+ <div class="bg-popover rounded-lg p-4 shadow-lg w-[400px] space-y-3" onclick={(e) => e.stopPropagation()}>
203
+ <h3 class="text-sm font-medium">Insert YouTube Video</h3>
204
+ <input
205
+ type="text"
206
+ class="w-full rounded-md border px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
207
+ placeholder="YouTube URL (e.g. https://youtube.com/watch?v=...)"
208
+ bind:value={youtubeUrl}
209
+ onkeydown={handleYoutubeKeydown}
210
+ />
211
+ <div class="flex justify-end gap-2">
212
+ <button
213
+ type="button"
214
+ class="rounded-md px-3 py-1.5 text-sm hover:bg-accent"
215
+ onclick={() => (showYoutubeDialog = false)}
216
+ >
217
+ Cancel
218
+ </button>
219
+ <button
220
+ type="button"
221
+ class="rounded-md bg-primary px-3 py-1.5 text-sm text-primary-foreground hover:bg-primary/90"
222
+ onclick={insertYoutube}
223
+ >
224
+ Insert
225
+ </button>
226
+ </div>
227
+ </div>
228
+ </div>
229
+ {/if}
230
+
231
+ <!-- Image URL dialog -->
232
+ {#if showImageDialog}
233
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
234
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
235
+ <div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onclick={() => (showImageDialog = false)}>
236
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
237
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
238
+ <div class="bg-popover rounded-lg p-4 shadow-lg w-[400px] space-y-3" onclick={(e) => e.stopPropagation()}>
239
+ <h3 class="text-sm font-medium">Insert Image</h3>
240
+ <input
241
+ type="text"
242
+ class="w-full rounded-md border px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
243
+ placeholder="Image URL"
244
+ bind:value={imageUrl}
245
+ onkeydown={handleImageKeydown}
246
+ />
247
+ <input
248
+ type="text"
249
+ class="w-full rounded-md border px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
250
+ placeholder="Alt text (optional)"
251
+ bind:value={imageAlt}
252
+ onkeydown={handleImageKeydown}
253
+ />
254
+ <div class="flex justify-end gap-2">
255
+ <button
256
+ type="button"
257
+ class="rounded-md px-3 py-1.5 text-sm hover:bg-accent"
258
+ onclick={() => (showImageDialog = false)}
259
+ >
260
+ Cancel
261
+ </button>
262
+ <button
263
+ type="button"
264
+ class="rounded-md bg-primary px-3 py-1.5 text-sm text-primary-foreground hover:bg-primary/90"
265
+ onclick={insertImage}
266
+ >
267
+ Insert
268
+ </button>
269
+ </div>
270
+ </div>
271
+ </div>
272
+ {/if}
273
+
274
+ <MediaLibrary
275
+ bind:open={mediaLibraryOpen}
276
+ accept={['image/jpeg', 'image/png', 'image/webp', 'image/svg+xml']}
277
+ onSelect={handleImageSelect}
278
+ />
@@ -0,0 +1,31 @@
1
+ <script lang="ts">
2
+ import { formatDate } from '$lib/utils/format.js'
3
+
4
+ export interface VersionOption {
5
+ id: string
6
+ label: string
7
+ status?: string
8
+ isLatest?: boolean
9
+ date: string
10
+ }
11
+
12
+ let {
13
+ options,
14
+ selected,
15
+ onChange,
16
+ }: {
17
+ options: VersionOption[]
18
+ selected: string
19
+ onChange: (id: string) => void
20
+ } = $props()
21
+ </script>
22
+
23
+ <select
24
+ class="rounded-md border border-stone-300 bg-white px-3 py-1.5 text-sm text-stone-700 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
25
+ value={selected}
26
+ onchange={(e) => onChange((e.target as HTMLSelectElement).value)}
27
+ >
28
+ {#each options as opt}
29
+ <option value={opt.id}>{opt.label} — {formatDate(opt.date)}</option>
30
+ {/each}
31
+ </select>