@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.
- package/LICENSE +661 -0
- package/biome.json +62 -0
- package/dist/assets/index-C9Y5-AKj.js +33 -0
- package/dist/assets/index-uVdiUjty.css +1 -0
- package/dist/index.html +20 -0
- package/index.html +19 -0
- package/package.json +43 -0
- package/src/AdminRoot.svelte +98 -0
- package/src/app.css +211 -0
- package/src/lib/Slot.svelte +65 -0
- package/src/lib/api/auth.ts +26 -0
- package/src/lib/api/client.ts +73 -0
- package/src/lib/api/files.ts +56 -0
- package/src/lib/api/globals.ts +13 -0
- package/src/lib/api/records.ts +102 -0
- package/src/lib/api/schema.ts +7 -0
- package/src/lib/api/versions.ts +40 -0
- package/src/lib/components/AdminHeader.svelte +107 -0
- package/src/lib/components/AdminSidebar.svelte +262 -0
- package/src/lib/components/DeleteDialog.svelte +58 -0
- package/src/lib/components/DocumentEditLayout.svelte +263 -0
- package/src/lib/components/DynamicForm.svelte +74 -0
- package/src/lib/components/KpiCard.svelte +75 -0
- package/src/lib/components/MediaLibrary.svelte +311 -0
- package/src/lib/components/Pagination.svelte +78 -0
- package/src/lib/components/RangeFilter.svelte +41 -0
- package/src/lib/components/RecordGrid.svelte +123 -0
- package/src/lib/components/RecordTable.svelte +156 -0
- package/src/lib/components/cells/CheckboxCell.svelte +10 -0
- package/src/lib/components/cells/ColorCell.svelte +15 -0
- package/src/lib/components/cells/DateCell.svelte +8 -0
- package/src/lib/components/cells/RelationshipCell.svelte +20 -0
- package/src/lib/components/cells/RichTextCell.svelte +21 -0
- package/src/lib/components/cells/SelectCell.svelte +26 -0
- package/src/lib/components/cells/TextCell.svelte +8 -0
- package/src/lib/components/cells/UploadCell.svelte +34 -0
- package/src/lib/components/cells/index.ts +28 -0
- package/src/lib/components/charts/TimeSeriesChart.svelte +184 -0
- package/src/lib/components/doc/ApiView.svelte +181 -0
- package/src/lib/components/doc/Autosave.svelte +102 -0
- package/src/lib/components/doc/DocHeader.svelte +86 -0
- package/src/lib/components/doc/DocMetaStrip.svelte +103 -0
- package/src/lib/components/doc/DocTabBar.svelte +26 -0
- package/src/lib/components/doc/HeaderModeSwitch.svelte +32 -0
- package/src/lib/components/doc/PublishButton.svelte +114 -0
- package/src/lib/components/doc/ScheduleModal.svelte +110 -0
- package/src/lib/components/doc/VersionHistory.svelte +20 -0
- package/src/lib/components/fields/ArrayFieldEditor.svelte +62 -0
- package/src/lib/components/fields/BlockCard.svelte +63 -0
- package/src/lib/components/fields/BlocksFieldEditor.svelte +83 -0
- package/src/lib/components/fields/CheckboxField.svelte +27 -0
- package/src/lib/components/fields/ColorField.svelte +46 -0
- package/src/lib/components/fields/DateField.svelte +52 -0
- package/src/lib/components/fields/EmailField.svelte +30 -0
- package/src/lib/components/fields/FileField.svelte +280 -0
- package/src/lib/components/fields/JsonField.svelte +145 -0
- package/src/lib/components/fields/NumberField.svelte +44 -0
- package/src/lib/components/fields/PasswordField.svelte +38 -0
- package/src/lib/components/fields/RelationshipField.svelte +271 -0
- package/src/lib/components/fields/RichTextField.svelte +139 -0
- package/src/lib/components/fields/SelectField.svelte +33 -0
- package/src/lib/components/fields/SlugField.svelte +70 -0
- package/src/lib/components/fields/TabsField.svelte +56 -0
- package/src/lib/components/fields/TagsField.svelte +85 -0
- package/src/lib/components/fields/TextField.svelte +36 -0
- package/src/lib/components/fields/TextareaField.svelte +32 -0
- package/src/lib/components/fields/UploadField.svelte +166 -0
- package/src/lib/components/fields/UploadFieldDispatch.svelte +21 -0
- package/src/lib/components/fields/UploadGalleryField.svelte +166 -0
- package/src/lib/components/fields/index.ts +22 -0
- package/src/lib/components/fields/registry.ts +58 -0
- package/src/lib/components/lexical/CustomHTMLComponent.svelte +52 -0
- package/src/lib/components/lexical/CustomHTMLNode.ts +94 -0
- package/src/lib/components/lexical/PullQuoteComponent.svelte +73 -0
- package/src/lib/components/lexical/PullQuoteNode.ts +112 -0
- package/src/lib/components/lexical/lexical-helpers.ts +24 -0
- package/src/lib/components/lexical/nodes.ts +8 -0
- package/src/lib/components/lexical/toolbar/EditorToolbar.svelte +159 -0
- package/src/lib/components/lexical/toolbar/InsertBlockDropdown.svelte +278 -0
- package/src/lib/components/versions/CompareSelector.svelte +31 -0
- package/src/lib/components/versions/FieldDiff.svelte +141 -0
- package/src/lib/components/versions/RestoreModal.svelte +67 -0
- package/src/lib/components/versions/StatusPill.svelte +21 -0
- package/src/lib/context.svelte.ts +156 -0
- package/src/lib/router/index.svelte.ts +282 -0
- package/src/lib/router/matcher.ts +52 -0
- package/src/lib/stores/branding.svelte.ts +74 -0
- package/src/lib/stores/schema.svelte.ts +17 -0
- package/src/lib/types/schema.ts +126 -0
- package/src/lib/utils/cn.ts +6 -0
- package/src/lib/utils/diff.ts +112 -0
- package/src/lib/utils/dirty.svelte.ts +50 -0
- package/src/lib/utils/format.ts +28 -0
- package/src/lib/utils/json-highlight.ts +34 -0
- package/src/lib/utils/slug.ts +8 -0
- package/src/main.ts +32 -0
- package/src/views/AdminLayout.svelte +73 -0
- package/src/views/AdsAnalyticsView.svelte +152 -0
- package/src/views/CollectionEditView.svelte +117 -0
- package/src/views/CollectionListView.svelte +347 -0
- package/src/views/CollectionNewView.svelte +68 -0
- package/src/views/CustomPageView.svelte +59 -0
- package/src/views/DashboardView.svelte +370 -0
- package/src/views/GlobalEditView.svelte +100 -0
- package/src/views/LoginView.svelte +231 -0
- package/src/views/NotFoundView.svelte +9 -0
- package/src/views/VersionDetailView.svelte +307 -0
- package/src/views/VersionsListView.svelte +201 -0
- package/tsconfig.json +25 -0
- 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,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>
|