@rigel-cms/rigel 1.2.2 → 1.3.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/package.json +1 -1
- package/src/editor/App.svelte +11 -9
- package/src/editor/i18n/de/fields.json +3 -0
- package/src/editor/i18n/de/media.json +9 -1
- package/src/editor/i18n/de/shell.json +1 -0
- package/src/editor/i18n/en/fields.json +3 -0
- package/src/editor/i18n/en/media.json +9 -1
- package/src/editor/i18n/en/shell.json +1 -0
- package/src/editor/i18n/es/fields.json +3 -0
- package/src/editor/i18n/es/media.json +9 -1
- package/src/editor/i18n/es/shell.json +1 -0
- package/src/editor/i18n/fr/fields.json +3 -0
- package/src/editor/i18n/fr/media.json +9 -1
- package/src/editor/i18n/fr/shell.json +1 -0
- package/src/editor/i18n/it/fields.json +3 -0
- package/src/editor/i18n/it/media.json +9 -1
- package/src/editor/i18n/it/shell.json +1 -0
- package/src/editor/i18n/ja/fields.json +3 -0
- package/src/editor/i18n/ja/media.json +9 -1
- package/src/editor/i18n/ja/shell.json +1 -0
- package/src/editor/i18n/nl/fields.json +3 -0
- package/src/editor/i18n/nl/media.json +9 -1
- package/src/editor/i18n/nl/shell.json +1 -0
- package/src/editor/i18n/ru/fields.json +3 -0
- package/src/editor/i18n/ru/media.json +9 -1
- package/src/editor/i18n/ru/shell.json +1 -0
- package/src/editor/i18n/zh/fields.json +3 -0
- package/src/editor/i18n/zh/media.json +9 -1
- package/src/editor/i18n/zh/shell.json +1 -0
- package/src/editor/lib/ImagePicker.svelte +27 -19
- package/src/editor/lib/SchemaField.svelte +2 -2
- package/src/editor/lib/SchemaPanel.svelte +76 -4
- package/src/editor/lib/VisualEditor.svelte +34 -1
- package/src/editor/lib/icons.ts +4 -0
- package/src/editor/styles.css +11 -3
- package/src/manifest/content-locales.ts +1 -1
- package/src/manifest/navigation.ts +3 -2
- package/src/media-types.ts +17 -0
- package/src/schema/types.ts +3 -1
- package/src/schema/validate.ts +8 -3
package/package.json
CHANGED
package/src/editor/App.svelte
CHANGED
|
@@ -449,7 +449,7 @@
|
|
|
449
449
|
|
|
450
450
|
const columns = $derived.by((): { key: string; label: string; image?: boolean }[] => {
|
|
451
451
|
if (sel?.kind === "menus") return [{ key: "title", label: $t("shell.name") }, { key: "slug", label: $t("shell.slug") }];
|
|
452
|
-
if (sel?.kind === "pages") return [{ key: "label", label: $t("shell.title") }, { key: "slug", label: $t("shell.slug") }, { key: "category", label: $t("shell.category") }];
|
|
452
|
+
if (sel?.kind === "pages") return [{ key: "label", label: $t("shell.title") }, { key: "slug", label: $t("shell.slug") }, { key: "path", label: $t("shell.path") }, { key: "category", label: $t("shell.category") }];
|
|
453
453
|
const source = sel?.kind === "documents" ? activeDocType : coll;
|
|
454
454
|
if (!source) return [];
|
|
455
455
|
const keys = ["title", "slug", ...(source.columns ?? (sel?.kind === "documents" ? [] : source.fields.filter(field => !isSeoField(field.key)).map(field => field.key)))];
|
|
@@ -510,11 +510,12 @@
|
|
|
510
510
|
const sortedPages = $derived.by(() => {
|
|
511
511
|
const rows = [
|
|
512
512
|
...visibleContent.pages.filter((p) => !p.shared && (pageCategory === undefined || p.category === pageCategory))
|
|
513
|
-
.map((p) => ({ slug: p.slug, title: p.label, category: p.category, draft: false, target: { kind: "page", slug: p.slug } as Sel })),
|
|
514
|
-
...
|
|
515
|
-
.map((doc) => ({ slug: effectiveSlug(doc.data, doc.slug), title: String(doc.data.title ?? doc.slug), category:
|
|
513
|
+
.map((p) => ({ slug: p.slug, path: previewPath(manifest.preview ?? {}, { kind: "page", slug: p.slug }), title: p.label, category: p.category, draft: false, target: { kind: "page", slug: p.slug } as Sel })),
|
|
514
|
+
...pageDocuments
|
|
515
|
+
.map((doc) => ({ slug: effectiveSlug(doc.data, doc.slug), path: previewPath(manifest.preview ?? {}, { kind: "document", type: doc.type, slug: effectiveSlug(doc.data, doc.slug) }), title: String(doc.data.title ?? doc.slug), category: docTypes.find(type => type.name === doc.type)?.category, draft: doc.data.draft === true, target: { kind: "document", type: doc.type, id: doc.id } as Sel }))
|
|
516
|
+
.filter(doc => pageCategory === undefined || doc.category === pageCategory),
|
|
516
517
|
];
|
|
517
|
-
const key = sortBy === "slug" || sortBy === "category" ? sortBy : "title";
|
|
518
|
+
const key = sortBy === "slug" || sortBy === "category" || sortBy === "path" ? sortBy : "title";
|
|
518
519
|
return rows.sort((a, b) => String(a[key] ?? "").localeCompare(String(b[key] ?? "")) * sortDir);
|
|
519
520
|
});
|
|
520
521
|
function documentCell(doc: ContentDocument, key: string): string {
|
|
@@ -531,7 +532,7 @@
|
|
|
531
532
|
: sel?.kind === "documents"
|
|
532
533
|
? typeDocuments.map((doc) => ({ slug: doc.slug, cells: Object.fromEntries(columns.map(column => [column.key, documentCell(doc, column.key)])), draft: doc.data.draft === true, target: { kind: "document", type: doc.type, id: doc.id } as Sel }))
|
|
533
534
|
: sel?.kind === "pages"
|
|
534
|
-
? sortedPages.map((p) => ({ slug: p.slug, cells: { label: p.title, slug: p.slug, category: p.category ?? "—" } as Record<string, string>, draft: p.draft, target: p.target }))
|
|
535
|
+
? sortedPages.map((p) => ({ slug: p.slug, cells: { label: p.title, slug: p.slug, path: p.path ?? "—", category: p.category ?? "—" } as Record<string, string>, draft: p.draft, target: p.target }))
|
|
535
536
|
: sortedPosts.map((p) => ({ slug: p.slug, cells: Object.fromEntries(columns.map((c) => [c.key, cellValue(p, c.key)])), draft: Boolean(p.frontmatter.draft), target: { kind: "post", collection: coll!.name, slug: p.slug } as Sel })));
|
|
536
537
|
const titleColumn = $derived(sel?.kind === "pages" ? "label" : "title");
|
|
537
538
|
|
|
@@ -589,9 +590,10 @@
|
|
|
589
590
|
if (target.kind === "menus") return sel.kind === "menu";
|
|
590
591
|
if (target.kind === "documents") return sel.kind === "document" && sel.type === target.type;
|
|
591
592
|
if (target.kind === "pages") {
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
593
|
+
const entry = sel.kind === "page" ? page : sel.kind === "document" ? docTypes.find(type => type.name === sel.type) : undefined;
|
|
594
|
+
if (!entry || entry.shared) return false;
|
|
595
|
+
const category = sidebar.some(node => node.kind === "link" && node.target.kind === "pages" && node.target.category === entry.category) ? entry.category : undefined;
|
|
596
|
+
return target.category === category;
|
|
595
597
|
}
|
|
596
598
|
return false;
|
|
597
599
|
}
|
|
@@ -30,6 +30,9 @@
|
|
|
30
30
|
"fields.documentSlugHint": "Ändert die URL, nicht die Dokumentkennung oder den Dateinamen. Die Website-Route muss die Slug-Metadaten verwenden.",
|
|
31
31
|
"fields.newPageNotice": "Neue Seite. Beim Speichern wird die JSON-Datei geschrieben. Die Route ist verfügbar, sobald die Datei auf der Website vorliegt.",
|
|
32
32
|
"fields.structure": "Struktur",
|
|
33
|
+
"fields.reorderSection": "{label} neu anordnen",
|
|
34
|
+
"fields.sectionReorderHint": "Zum Anordnen ziehen oder die Pfeiltasten nach oben und unten verwenden. Escape bricht das Ziehen ab. Änderungen müssen gespeichert werden.",
|
|
35
|
+
"fields.sectionMoved": "{label} an Position {position} von {count} verschoben.",
|
|
33
36
|
"fields.moveSectionUp": "{label} nach oben verschieben",
|
|
34
37
|
"fields.moveSectionDown": "{label} nach unten verschieben",
|
|
35
38
|
"fields.duplicateSection": "{label} duplizieren",
|
|
@@ -162,5 +162,13 @@
|
|
|
162
162
|
"media.photoImportFailedStatus": "Fotoimport fehlgeschlagen ({status})",
|
|
163
163
|
"media.invalidImportedUrl": "Ungültige URL des importierten Fotos",
|
|
164
164
|
"media.providerVideo": "Video von {provider}",
|
|
165
|
-
"media.embedCaption": "{provider} · {title}"
|
|
165
|
+
"media.embedCaption": "{provider} · {title}",
|
|
166
|
+
"media.chooseFile": "Datei auswählen",
|
|
167
|
+
"media.removeFile": "Datei entfernen",
|
|
168
|
+
"media.noFile": "Keine Datei",
|
|
169
|
+
"media.useFile": "Datei verwenden",
|
|
170
|
+
"media.fileSources": "Dateiquellen",
|
|
171
|
+
"media.uploadingFile": "Datei wird hochgeladen…",
|
|
172
|
+
"media.chooseAcceptedFile": "Wählen Sie eine unterstützte Datei passend zu: {accept}.",
|
|
173
|
+
"media.uploadFailedGeneric": "Datei-Upload fehlgeschlagen"
|
|
166
174
|
}
|
|
@@ -94,6 +94,7 @@
|
|
|
94
94
|
"shell.eraseData": "Daten löschen",
|
|
95
95
|
"shell.eraseBrowserDataDescription": "Dadurch werden Rigel-Einstellungen, API-Schlüssel, die GitHub-Anmeldung und ungespeicherte Entwürfe aus diesem Browser entfernt. Gespeicherte Repository-Dateien bleiben unverändert. Der Editor wird neu geladen.",
|
|
96
96
|
"shell.name": "Name",
|
|
97
|
+
"shell.path": "Pfad",
|
|
97
98
|
"shell.slug": "Slug",
|
|
98
99
|
"shell.title": "Titel",
|
|
99
100
|
"shell.category": "Kategorie",
|
|
@@ -30,6 +30,9 @@
|
|
|
30
30
|
"fields.documentSlugHint": "Changes the URL, not the document identifier or filename. The website route must use the slug metadata.",
|
|
31
31
|
"fields.newPageNotice": "New page. Save writes the JSON file; the route appears once the site has the file.",
|
|
32
32
|
"fields.structure": "Structure",
|
|
33
|
+
"fields.reorderSection": "Reorder {label}",
|
|
34
|
+
"fields.sectionReorderHint": "Drag to reorder, or use Arrow Up and Arrow Down. Escape cancels dragging. Changes require Save.",
|
|
35
|
+
"fields.sectionMoved": "{label} moved to position {position} of {count}.",
|
|
33
36
|
"fields.moveSectionUp": "Move {label} up",
|
|
34
37
|
"fields.moveSectionDown": "Move {label} down",
|
|
35
38
|
"fields.duplicateSection": "Duplicate {label}",
|
|
@@ -162,5 +162,13 @@
|
|
|
162
162
|
"media.photoImportFailedStatus": "Photo import failed ({status})",
|
|
163
163
|
"media.invalidImportedUrl": "Invalid imported photo URL",
|
|
164
164
|
"media.providerVideo": "{provider} Video",
|
|
165
|
-
"media.embedCaption": "{provider} · {title}"
|
|
165
|
+
"media.embedCaption": "{provider} · {title}",
|
|
166
|
+
"media.chooseFile": "Choose file",
|
|
167
|
+
"media.removeFile": "Remove file",
|
|
168
|
+
"media.noFile": "No file",
|
|
169
|
+
"media.useFile": "Use file",
|
|
170
|
+
"media.fileSources": "File sources",
|
|
171
|
+
"media.uploadingFile": "Uploading file…",
|
|
172
|
+
"media.chooseAcceptedFile": "Choose a supported file matching: {accept}.",
|
|
173
|
+
"media.uploadFailedGeneric": "File upload failed"
|
|
166
174
|
}
|
|
@@ -94,6 +94,7 @@
|
|
|
94
94
|
"shell.eraseData": "Erase data",
|
|
95
95
|
"shell.eraseBrowserDataDescription": "This removes Rigel settings, API keys, GitHub login and unsaved drafts from this browser. Saved repository files will not change. The editor will reload.",
|
|
96
96
|
"shell.name": "Name",
|
|
97
|
+
"shell.path": "Path",
|
|
97
98
|
"shell.slug": "Slug",
|
|
98
99
|
"shell.title": "Title",
|
|
99
100
|
"shell.category": "Category",
|
|
@@ -30,6 +30,9 @@
|
|
|
30
30
|
"fields.documentSlugHint": "Cambia la URL, no el identificador del documento ni el nombre del archivo. La ruta del sitio web debe usar el slug de los metadatos.",
|
|
31
31
|
"fields.newPageNotice": "Página nueva. Al pulsar Guardar se escribe el archivo JSON; la ruta aparece cuando el sitio dispone del archivo.",
|
|
32
32
|
"fields.structure": "Estructura",
|
|
33
|
+
"fields.reorderSection": "Reordenar {label}",
|
|
34
|
+
"fields.sectionReorderHint": "Arrastra para reordenar o usa las flechas arriba y abajo. Escape cancela el arrastre. Los cambios requieren Guardar.",
|
|
35
|
+
"fields.sectionMoved": "{label} se ha movido a la posición {position} de {count}.",
|
|
33
36
|
"fields.moveSectionUp": "Subir la sección «{label}»",
|
|
34
37
|
"fields.moveSectionDown": "Bajar la sección «{label}»",
|
|
35
38
|
"fields.duplicateSection": "Duplicar la sección «{label}»",
|
|
@@ -162,5 +162,13 @@
|
|
|
162
162
|
"media.photoImportFailedStatus": "No se pudo importar la foto ({status})",
|
|
163
163
|
"media.invalidImportedUrl": "URL de la foto importada no válida",
|
|
164
164
|
"media.providerVideo": "Video de {provider}",
|
|
165
|
-
"media.embedCaption": "{provider} · {title}"
|
|
165
|
+
"media.embedCaption": "{provider} · {title}",
|
|
166
|
+
"media.chooseFile": "Elegir archivo",
|
|
167
|
+
"media.removeFile": "Quitar archivo",
|
|
168
|
+
"media.noFile": "Sin archivo",
|
|
169
|
+
"media.useFile": "Usar archivo",
|
|
170
|
+
"media.fileSources": "Fuentes de archivos",
|
|
171
|
+
"media.uploadingFile": "Subiendo archivo…",
|
|
172
|
+
"media.chooseAcceptedFile": "Elige un archivo compatible con: {accept}.",
|
|
173
|
+
"media.uploadFailedGeneric": "Error al subir el archivo"
|
|
166
174
|
}
|
|
@@ -94,6 +94,7 @@
|
|
|
94
94
|
"shell.eraseData": "Borrar datos",
|
|
95
95
|
"shell.eraseBrowserDataDescription": "Se eliminarán de este navegador la configuración de Rigel, las claves de API, el inicio de sesión de GitHub y los borradores sin guardar. Los archivos guardados en el repositorio no cambiarán. El editor se volverá a cargar.",
|
|
96
96
|
"shell.name": "Nombre",
|
|
97
|
+
"shell.path": "Ruta",
|
|
97
98
|
"shell.slug": "Slug",
|
|
98
99
|
"shell.title": "Título",
|
|
99
100
|
"shell.category": "Categoría",
|
|
@@ -30,6 +30,9 @@
|
|
|
30
30
|
"fields.documentSlugHint": "Modifie l’URL, pas l’identifiant du document ni le nom du fichier. La route du site doit utiliser la métadonnée slug.",
|
|
31
31
|
"fields.newPageNotice": "Nouvelle page. Enregistrer écrit le fichier JSON ; la route apparaît une fois le fichier présent sur le site.",
|
|
32
32
|
"fields.structure": "Structure",
|
|
33
|
+
"fields.reorderSection": "Réordonner {label}",
|
|
34
|
+
"fields.sectionReorderHint": "Faites glisser pour réordonner, ou utilisez les flèches haut et bas. Échap annule le déplacement. Les modifications nécessitent Enregistrer.",
|
|
35
|
+
"fields.sectionMoved": "{label} déplacé à la position {position} sur {count}.",
|
|
33
36
|
"fields.moveSectionUp": "Monter la section « {label} »",
|
|
34
37
|
"fields.moveSectionDown": "Descendre la section « {label} »",
|
|
35
38
|
"fields.duplicateSection": "Dupliquer la section « {label} »",
|
|
@@ -162,5 +162,13 @@
|
|
|
162
162
|
"media.photoImportFailedStatus": "Échec de l’importation de la photo ({status})",
|
|
163
163
|
"media.invalidImportedUrl": "URL de la photo importée invalide",
|
|
164
164
|
"media.providerVideo": "Vidéo {provider}",
|
|
165
|
-
"media.embedCaption": "{provider} · {title}"
|
|
165
|
+
"media.embedCaption": "{provider} · {title}",
|
|
166
|
+
"media.chooseFile": "Choisir un fichier",
|
|
167
|
+
"media.removeFile": "Retirer le fichier",
|
|
168
|
+
"media.noFile": "Aucun fichier",
|
|
169
|
+
"media.useFile": "Utiliser le fichier",
|
|
170
|
+
"media.fileSources": "Sources de fichiers",
|
|
171
|
+
"media.uploadingFile": "Téléversement du fichier…",
|
|
172
|
+
"media.chooseAcceptedFile": "Choisissez un fichier pris en charge correspondant à : {accept}.",
|
|
173
|
+
"media.uploadFailedGeneric": "Échec du téléversement"
|
|
166
174
|
}
|
|
@@ -94,6 +94,7 @@
|
|
|
94
94
|
"shell.eraseData": "Effacer les données",
|
|
95
95
|
"shell.eraseBrowserDataDescription": "Cette action supprime les paramètres Rigel, les clés API, la connexion GitHub et les brouillons non enregistrés de ce navigateur. Les fichiers enregistrés dans le dépôt ne seront pas modifiés. L’éditeur sera rechargé.",
|
|
96
96
|
"shell.name": "Nom",
|
|
97
|
+
"shell.path": "Chemin",
|
|
97
98
|
"shell.slug": "Identifiant d’URL",
|
|
98
99
|
"shell.title": "Titre",
|
|
99
100
|
"shell.category": "Catégorie",
|
|
@@ -30,6 +30,9 @@
|
|
|
30
30
|
"fields.documentSlugHint": "Modifica l’URL, non l’identificatore del documento né il nome del file. La rotta del sito deve usare lo slug nei metadati.",
|
|
31
31
|
"fields.newPageNotice": "Nuova pagina. Salva scrive il file JSON; la rotta appare quando il file è presente nel sito.",
|
|
32
32
|
"fields.structure": "Struttura",
|
|
33
|
+
"fields.reorderSection": "Riordina {label}",
|
|
34
|
+
"fields.sectionReorderHint": "Trascina per riordinare o usa le frecce su e giù. Escape annulla il trascinamento. Le modifiche richiedono Salva.",
|
|
35
|
+
"fields.sectionMoved": "{label} spostato alla posizione {position} di {count}.",
|
|
33
36
|
"fields.moveSectionUp": "Sposta in alto {label}",
|
|
34
37
|
"fields.moveSectionDown": "Sposta in basso {label}",
|
|
35
38
|
"fields.duplicateSection": "Duplica {label}",
|
|
@@ -162,5 +162,13 @@
|
|
|
162
162
|
"media.photoImportFailedStatus": "Importazione della foto non riuscita ({status})",
|
|
163
163
|
"media.invalidImportedUrl": "URL della foto importata non valido",
|
|
164
164
|
"media.providerVideo": "Video {provider}",
|
|
165
|
-
"media.embedCaption": "{provider} · {title}"
|
|
165
|
+
"media.embedCaption": "{provider} · {title}",
|
|
166
|
+
"media.chooseFile": "Scegli file",
|
|
167
|
+
"media.removeFile": "Rimuovi file",
|
|
168
|
+
"media.noFile": "Nessun file",
|
|
169
|
+
"media.useFile": "Usa file",
|
|
170
|
+
"media.fileSources": "Origini dei file",
|
|
171
|
+
"media.uploadingFile": "Caricamento del file…",
|
|
172
|
+
"media.chooseAcceptedFile": "Scegli un file supportato corrispondente a: {accept}.",
|
|
173
|
+
"media.uploadFailedGeneric": "Caricamento del file non riuscito"
|
|
166
174
|
}
|
|
@@ -94,6 +94,7 @@
|
|
|
94
94
|
"shell.eraseData": "Cancella dati",
|
|
95
95
|
"shell.eraseBrowserDataDescription": "Verranno rimossi da questo browser le impostazioni di Rigel, le chiavi API, l’accesso a GitHub e le bozze non salvate. I file salvati nel repository non verranno modificati. L’editor verrà ricaricato.",
|
|
96
96
|
"shell.name": "Nome",
|
|
97
|
+
"shell.path": "Percorso",
|
|
97
98
|
"shell.slug": "Slug",
|
|
98
99
|
"shell.title": "Titolo",
|
|
99
100
|
"shell.category": "Categoria",
|
|
@@ -30,6 +30,9 @@
|
|
|
30
30
|
"fields.documentSlugHint": "ドキュメントの識別子やファイル名ではなく、URLを変更します。ウェブサイトのルートではslugメタデータを使う必要があります。",
|
|
31
31
|
"fields.newPageNotice": "新しいページです。保存するとJSONファイルが書き込まれ、サイトにファイルが追加されるとルートが利用可能になります。",
|
|
32
32
|
"fields.structure": "構造",
|
|
33
|
+
"fields.reorderSection": "{label}を並べ替え",
|
|
34
|
+
"fields.sectionReorderHint": "ドラッグするか上下の矢印キーで並べ替えます。Escapeでドラッグをキャンセルします。変更には保存が必要です。",
|
|
35
|
+
"fields.sectionMoved": "{label}を{count}件中{position}番目に移動しました。",
|
|
33
36
|
"fields.moveSectionUp": "{label}を上へ移動",
|
|
34
37
|
"fields.moveSectionDown": "{label}を下へ移動",
|
|
35
38
|
"fields.duplicateSection": "{label}を複製",
|
|
@@ -162,5 +162,13 @@
|
|
|
162
162
|
"media.photoImportFailedStatus": "写真のインポートに失敗しました({status})",
|
|
163
163
|
"media.invalidImportedUrl": "インポートした写真のURLが無効です",
|
|
164
164
|
"media.providerVideo": "{provider}の動画",
|
|
165
|
-
"media.embedCaption": "{provider} · {title}"
|
|
165
|
+
"media.embedCaption": "{provider} · {title}",
|
|
166
|
+
"media.chooseFile": "ファイルを選択",
|
|
167
|
+
"media.removeFile": "ファイルを解除",
|
|
168
|
+
"media.noFile": "ファイルなし",
|
|
169
|
+
"media.useFile": "ファイルを使用",
|
|
170
|
+
"media.fileSources": "ファイルのソース",
|
|
171
|
+
"media.uploadingFile": "ファイルをアップロード中…",
|
|
172
|
+
"media.chooseAcceptedFile": "対応する形式のファイルを選択してください:{accept}。",
|
|
173
|
+
"media.uploadFailedGeneric": "ファイルのアップロードに失敗しました"
|
|
166
174
|
}
|
|
@@ -94,6 +94,7 @@
|
|
|
94
94
|
"shell.eraseData": "データを消去",
|
|
95
95
|
"shell.eraseBrowserDataDescription": "このブラウザーからRigelの設定、APIキー、GitHubのログイン情報、未保存の下書きを削除します。リポジトリに保存済みのファイルは変更されません。エディターが再読み込みされます。",
|
|
96
96
|
"shell.name": "名前",
|
|
97
|
+
"shell.path": "パス",
|
|
97
98
|
"shell.slug": "スラッグ",
|
|
98
99
|
"shell.title": "タイトル",
|
|
99
100
|
"shell.category": "カテゴリー",
|
|
@@ -30,6 +30,9 @@
|
|
|
30
30
|
"fields.documentSlugHint": "Wijzigt de URL, niet de document-ID of bestandsnaam. De websiteroute moet de slugmetadata gebruiken.",
|
|
31
31
|
"fields.newPageNotice": "Nieuwe pagina. Opslaan schrijft het JSON-bestand weg. De route is beschikbaar zodra de website het bestand heeft.",
|
|
32
32
|
"fields.structure": "Structuur",
|
|
33
|
+
"fields.reorderSection": "{label} herschikken",
|
|
34
|
+
"fields.sectionReorderHint": "Sleep om te herschikken of gebruik de pijltjestoetsen omhoog en omlaag. Escape annuleert het slepen. Wijzigingen moeten worden opgeslagen.",
|
|
35
|
+
"fields.sectionMoved": "{label} verplaatst naar positie {position} van {count}.",
|
|
33
36
|
"fields.moveSectionUp": "{label} omhoog verplaatsen",
|
|
34
37
|
"fields.moveSectionDown": "{label} omlaag verplaatsen",
|
|
35
38
|
"fields.duplicateSection": "{label} dupliceren",
|
|
@@ -162,5 +162,13 @@
|
|
|
162
162
|
"media.photoImportFailedStatus": "Foto importeren mislukt ({status})",
|
|
163
163
|
"media.invalidImportedUrl": "Ongeldige URL van de geïmporteerde foto",
|
|
164
164
|
"media.providerVideo": "Video van {provider}",
|
|
165
|
-
"media.embedCaption": "{provider} · {title}"
|
|
165
|
+
"media.embedCaption": "{provider} · {title}",
|
|
166
|
+
"media.chooseFile": "Bestand kiezen",
|
|
167
|
+
"media.removeFile": "Bestand verwijderen",
|
|
168
|
+
"media.noFile": "Geen bestand",
|
|
169
|
+
"media.useFile": "Bestand gebruiken",
|
|
170
|
+
"media.fileSources": "Bestandsbronnen",
|
|
171
|
+
"media.uploadingFile": "Bestand uploaden…",
|
|
172
|
+
"media.chooseAcceptedFile": "Kies een ondersteund bestand dat overeenkomt met: {accept}.",
|
|
173
|
+
"media.uploadFailedGeneric": "Uploaden mislukt"
|
|
166
174
|
}
|
|
@@ -94,6 +94,7 @@
|
|
|
94
94
|
"shell.eraseData": "Gegevens wissen",
|
|
95
95
|
"shell.eraseBrowserDataDescription": "Hiermee verwijder je Rigel-instellingen, API-sleutels, de GitHub-aanmelding en niet-opgeslagen concepten uit deze browser. Opgeslagen repositorybestanden blijven ongewijzigd. De editor wordt opnieuw geladen.",
|
|
96
96
|
"shell.name": "Naam",
|
|
97
|
+
"shell.path": "Pad",
|
|
97
98
|
"shell.slug": "Slug",
|
|
98
99
|
"shell.title": "Titel",
|
|
99
100
|
"shell.category": "Categorie",
|
|
@@ -30,6 +30,9 @@
|
|
|
30
30
|
"fields.documentSlugHint": "Меняет URL, но не идентификатор документа или имя файла. Маршрут сайта должен использовать метаданные slug.",
|
|
31
31
|
"fields.newPageNotice": "Новая страница. Кнопка «Сохранить» записывает JSON-файл; маршрут появится, когда файл будет доступен сайту.",
|
|
32
32
|
"fields.structure": "Структура",
|
|
33
|
+
"fields.reorderSection": "Изменить порядок: {label}",
|
|
34
|
+
"fields.sectionReorderHint": "Перетащите для изменения порядка или используйте стрелки вверх и вниз. Escape отменяет перетаскивание. Изменения требуют сохранения.",
|
|
35
|
+
"fields.sectionMoved": "{label}: перемещено на позицию {position} из {count}.",
|
|
33
36
|
"fields.moveSectionUp": "Переместить вверх: {label}",
|
|
34
37
|
"fields.moveSectionDown": "Переместить вниз: {label}",
|
|
35
38
|
"fields.duplicateSection": "Дублировать: {label}",
|
|
@@ -162,5 +162,13 @@
|
|
|
162
162
|
"media.photoImportFailedStatus": "Не удалось импортировать фотографию ({status})",
|
|
163
163
|
"media.invalidImportedUrl": "Некорректный URL импортированной фотографии",
|
|
164
164
|
"media.providerVideo": "Видео из сервиса {provider}",
|
|
165
|
-
"media.embedCaption": "{provider} · {title}"
|
|
165
|
+
"media.embedCaption": "{provider} · {title}",
|
|
166
|
+
"media.chooseFile": "Выбрать файл",
|
|
167
|
+
"media.removeFile": "Убрать файл",
|
|
168
|
+
"media.noFile": "Нет файла",
|
|
169
|
+
"media.useFile": "Использовать файл",
|
|
170
|
+
"media.fileSources": "Источники файлов",
|
|
171
|
+
"media.uploadingFile": "Загрузка файла…",
|
|
172
|
+
"media.chooseAcceptedFile": "Выберите поддерживаемый файл формата: {accept}.",
|
|
173
|
+
"media.uploadFailedGeneric": "Не удалось загрузить файл"
|
|
166
174
|
}
|
|
@@ -94,6 +94,7 @@
|
|
|
94
94
|
"shell.eraseData": "Удалить данные",
|
|
95
95
|
"shell.eraseBrowserDataDescription": "Из этого браузера будут удалены настройки Rigel, ключи API, данные входа в GitHub и несохранённые черновики. Сохранённые файлы репозитория не изменятся. Редактор перезагрузится.",
|
|
96
96
|
"shell.name": "Название",
|
|
97
|
+
"shell.path": "Путь",
|
|
97
98
|
"shell.slug": "Слаг",
|
|
98
99
|
"shell.title": "Заголовок",
|
|
99
100
|
"shell.category": "Категория",
|
|
@@ -30,6 +30,9 @@
|
|
|
30
30
|
"fields.documentSlugHint": "仅更改 URL,不会更改文档标识符或文件名。网站路由须使用 slug 元数据。",
|
|
31
31
|
"fields.newPageNotice": "新页面。保存会写入 JSON 文件;网站获取该文件后,路由才会生效。",
|
|
32
32
|
"fields.structure": "结构",
|
|
33
|
+
"fields.reorderSection": "重新排列{label}",
|
|
34
|
+
"fields.sectionReorderHint": "拖动或使用上下方向键重新排列。Escape 可取消拖动。更改需要保存。",
|
|
35
|
+
"fields.sectionMoved": "已将{label}移至第{position}项,共{count}项。",
|
|
33
36
|
"fields.moveSectionUp": "上移{label}",
|
|
34
37
|
"fields.moveSectionDown": "下移{label}",
|
|
35
38
|
"fields.duplicateSection": "复制{label}",
|
|
@@ -162,5 +162,13 @@
|
|
|
162
162
|
"media.photoImportFailedStatus": "照片导入失败({status})",
|
|
163
163
|
"media.invalidImportedUrl": "导入照片的 URL 无效",
|
|
164
164
|
"media.providerVideo": "{provider} 视频",
|
|
165
|
-
"media.embedCaption": "{provider} · {title}"
|
|
165
|
+
"media.embedCaption": "{provider} · {title}",
|
|
166
|
+
"media.chooseFile": "选择文件",
|
|
167
|
+
"media.removeFile": "移除文件",
|
|
168
|
+
"media.noFile": "无文件",
|
|
169
|
+
"media.useFile": "使用文件",
|
|
170
|
+
"media.fileSources": "文件来源",
|
|
171
|
+
"media.uploadingFile": "正在上传文件…",
|
|
172
|
+
"media.chooseAcceptedFile": "请选择符合以下格式的受支持文件:{accept}。",
|
|
173
|
+
"media.uploadFailedGeneric": "文件上传失败"
|
|
166
174
|
}
|
|
@@ -94,6 +94,7 @@
|
|
|
94
94
|
"shell.eraseData": "清除数据",
|
|
95
95
|
"shell.eraseBrowserDataDescription": "这会清除此浏览器中的 Rigel 设置、API 密钥、GitHub 登录信息和未保存的草稿。已保存的仓库文件不会改变。编辑器将重新加载。",
|
|
96
96
|
"shell.name": "名称",
|
|
97
|
+
"shell.path": "路径",
|
|
97
98
|
"shell.slug": "路径标识",
|
|
98
99
|
"shell.title": "标题",
|
|
99
100
|
"shell.category": "分类",
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import ActionDialog from "./ActionDialog.svelte";
|
|
6
6
|
import Select from "./Select.svelte";
|
|
7
7
|
import { getSettings, setSettings } from "./settings";
|
|
8
|
-
import { DEFAULT_MAX_FILE_SIZE, MEDIA_CATEGORIES, mediaMetadata, mediaAccept, safeMediaName } from "../../media-types";
|
|
8
|
+
import { DEFAULT_MAX_FILE_SIZE, MEDIA_CATEGORIES, mediaMetadata, mediaAccept, safeMediaName, matchesMediaAccept, safeFileUrl } from "../../media-types";
|
|
9
9
|
import config from "virtual:rigel-config";
|
|
10
10
|
import { parseEmbedUrl, embedWatchUrl, type EmbedProvider } from "./embeds";
|
|
11
11
|
import type { EmbedData } from "../blocks/embed";
|
|
@@ -13,12 +13,13 @@
|
|
|
13
13
|
import { filterMedia, modifiedMonth, mediaSize, mediaIcon, assetFile, type MediaAsset } from "./media-library";
|
|
14
14
|
import { searchImages, importStockImage, importImageUrl, uploadMedia, safeImageUrl, STOCK_PROVIDERS, type StockResult, type StockSource } from "./images";
|
|
15
15
|
|
|
16
|
-
let { value = "", altText = "", triggerLabel, kind = "image", video, onvideo, embed, onembed, onpick, inline = false, toolbar = false, hiddenTrigger = false, onopen, onclose }: {
|
|
16
|
+
let { value = "", altText = "", triggerLabel, kind = "image", accept, video, onvideo, embed, onembed, onpick, inline = false, toolbar = false, hiddenTrigger = false, onopen, onclose }: {
|
|
17
17
|
video?: VideoData;
|
|
18
18
|
onvideo?: (video: VideoData) => void;
|
|
19
19
|
embed?: EmbedData;
|
|
20
20
|
onembed?: (embed: EmbedData) => void;
|
|
21
|
-
kind?: "image" | "video";
|
|
21
|
+
kind?: "image" | "video" | "file";
|
|
22
|
+
accept?: string;
|
|
22
23
|
value?: string; onpick?: (url: string, alt: string, credit?: StockResult) => void;
|
|
23
24
|
altText?: string; triggerLabel?: string;
|
|
24
25
|
inline?: boolean; toolbar?: boolean; hiddenTrigger?: boolean; onopen?: () => void; onclose?: () => void;
|
|
@@ -30,7 +31,7 @@
|
|
|
30
31
|
const providerTab = $derived(tab === "youtube" || tab === "vimeo");
|
|
31
32
|
const editing = $derived(!!embed || !!value);
|
|
32
33
|
const stockKeys = $derived(open ? getSettings().apiKeys : null);
|
|
33
|
-
const tabs = $derived((kind === "video" ? ["library", "upload", "url", "youtube", "vimeo"] : ["library", "upload", "url", "picsum", ...Object.keys(STOCK_PROVIDERS).filter(source => stockKeys?.[source as keyof typeof STOCK_PROVIDERS]?.trim())]) as (typeof tab)[]);
|
|
34
|
+
const tabs = $derived((kind === "file" ? ["library", "upload"] : kind === "video" ? ["library", "upload", "url", "youtube", "vimeo"] : ["library", "upload", "url", "picsum", ...Object.keys(STOCK_PROVIDERS).filter(source => stockKeys?.[source as keyof typeof STOCK_PROVIDERS]?.trim())]) as (typeof tab)[]);
|
|
34
35
|
let videoTitle = $state("");
|
|
35
36
|
let privacy = $state(true);
|
|
36
37
|
let muted = $state(false);
|
|
@@ -73,6 +74,7 @@
|
|
|
73
74
|
const months = $derived([...new Set(assets.map(a => modifiedMonth(a.modified)).filter(Boolean))].sort().reverse());
|
|
74
75
|
function resetFilters() { filter = ""; format = ""; month = ""; }
|
|
75
76
|
function selectAsset(asset: MediaAsset) {
|
|
77
|
+
if (kind === "file" && (!safeFileUrl(asset.url) || !matchesMediaAccept(asset.url, accept))) { error = $t("media.chooseAcceptedFile", { accept: accept ?? mediaAccept() }); return; }
|
|
76
78
|
librarySelection = asset.url; selected = {url: asset.url, asset};
|
|
77
79
|
metaName = asset.name; metaTitle = asset.title ?? ""; metaDescription = asset.description ?? ""; metaAlt = asset.alt ?? "";
|
|
78
80
|
alt = value ? altText : asset.alt ?? "";
|
|
@@ -117,7 +119,7 @@
|
|
|
117
119
|
const response = await fetch("/rigel/media.json", { cache: "no-store", headers: { "Accept-Language": $locale } });
|
|
118
120
|
if (!response.ok) throw new Error($t("media.listingUnavailable"));
|
|
119
121
|
const data = await response.json();
|
|
120
|
-
assets = (data.assets ?? data.images).filter((asset: MediaAsset) => inline || asset.type?.startsWith(kind === "video" ? "video/" : "image/"));
|
|
122
|
+
assets = (data.assets ?? data.images).filter((asset: MediaAsset) => inline || (kind === "file" ? safeFileUrl(asset.url) && matchesMediaAccept(asset.url, accept) : asset.type?.startsWith(kind === "video" ? "video/" : "image/")));
|
|
121
123
|
writable = data.writable === true;
|
|
122
124
|
folder = data.folder;
|
|
123
125
|
}
|
|
@@ -162,11 +164,13 @@
|
|
|
162
164
|
if (!selected || picking) return;
|
|
163
165
|
const request = ++requestId;
|
|
164
166
|
const choice = selected;
|
|
167
|
+
if (kind === "file" && !matchesMediaAccept(choice.file?.name ?? choice.url, accept)) { error = $t("media.chooseAcceptedFile", { accept: accept ?? mediaAccept() }); return; }
|
|
165
168
|
const metadata = metadataImport ? { title: metaTitle.trim(), description: metaDescription.trim(), ...(imageImport ? { alt: alt.trim() } : {}) } : null;
|
|
166
169
|
picking = true; error = "";
|
|
167
170
|
try {
|
|
168
171
|
const url = choice.file ? await uploadMedia(choice.file) : choice.stock ? await importStockImage(choice.stock) : needsImport ? await importImageUrl(choice.url) : choice.url;
|
|
169
172
|
if (!safeImageUrl(url)) throw new Error($t("media.unsafeMediaUrl"));
|
|
173
|
+
if (kind === "file" && (!safeFileUrl(url) || !matchesMediaAccept(url, accept))) throw new Error($t("media.chooseAcceptedFile", { accept: accept ?? mediaAccept() }));
|
|
170
174
|
if (metadata) {
|
|
171
175
|
const saved = await fetch("/rigel/api/local/media", { method: "PUT", headers: { "Content-Type": "application/json", "Accept-Language": $locale }, body: JSON.stringify({ url, ...metadata }) });
|
|
172
176
|
if (!saved.ok) throw new Error($t("media.uploadedMetadataFailed", { status: saved.status }));
|
|
@@ -179,15 +183,16 @@
|
|
|
179
183
|
else onpick?.(url, alt, choice.stock);
|
|
180
184
|
close();
|
|
181
185
|
}
|
|
182
|
-
} catch (e) { if (request === requestId) error = e instanceof Error ? e.message : $t("media.photoImportFailed"); }
|
|
186
|
+
} catch (e) { if (request === requestId) error = e instanceof Error ? e.message : $t(kind === "file" ? "media.uploadFailedGeneric" : "media.photoImportFailed"); }
|
|
183
187
|
finally { if (request === requestId) picking = false; }
|
|
184
188
|
}
|
|
185
189
|
function upload(file?: File) {
|
|
186
190
|
if (!file || picking) return;
|
|
187
191
|
error = "";
|
|
188
192
|
const limit = maxFileSize;
|
|
189
|
-
if (
|
|
190
|
-
|
|
193
|
+
if (kind === "file" && !matchesMediaAccept(file.name, accept)) { error = $t("media.chooseAcceptedFile", { accept: accept ?? mediaAccept() }); return; }
|
|
194
|
+
if (!safeMediaName(file.name) || (!inline && kind !== "file" && mediaMetadata(file.name)?.category !== (kind === "video" ? "Video" : "Images")) || file.size > limit) {
|
|
195
|
+
error = $t(inline || kind === "file" ? "media.chooseSupportedFile" : kind === "video" ? "media.chooseSupportedVideo" : "media.chooseSupportedImage", { limit: limit / 1024 / 1024 }); return;
|
|
191
196
|
}
|
|
192
197
|
const type = mediaMetadata(file.name)!.type;
|
|
193
198
|
releaseFile(); objectURL = URL.createObjectURL(new Blob([file], { type: type === "text/plain" ? "text/plain; charset=utf-8" : type }));
|
|
@@ -225,9 +230,11 @@
|
|
|
225
230
|
<button bind:this={trigger} data-image-trigger={kind === "image" ? "" : undefined} type="button" aria-label={triggerLabel ?? $t("media.insertImage")} data-tooltip={triggerLabel ?? $t("media.insertImage")} hidden={hiddenTrigger} onmousedown={event => event.preventDefault()} onclick={launch}><Icon name="image" size={18} /></button>
|
|
226
231
|
{:else}
|
|
227
232
|
<div class="image-field">
|
|
228
|
-
{#if
|
|
233
|
+
{#if kind === "file"}
|
|
234
|
+
{#if value && safeFileUrl(value)}<a href={value} download>{assetFile(value)}</a>{:else}<span>{$t("media.noFile")}</span>{/if}
|
|
235
|
+
{:else if value}<img src={value} alt="" class="preview" />{:else}<div class="preview empty">{$t("media.noImage")}</div>{/if}
|
|
229
236
|
<button bind:this={trigger} type="button" class="outlined" onclick={launch}>{$t("media.choose")}</button>
|
|
230
|
-
{#if value && onpick}<button type="button" class="outlined" aria-label={$t("media.removeImage")} data-tooltip={$t("media.removeImage")} onclick={() => onpick?.("", "")}><Icon name="x" size={14} /></button>{/if}
|
|
237
|
+
{#if value && onpick}<button type="button" class="outlined icon-only" aria-label={$t(kind === "file" ? "media.removeFile" : "media.removeImage")} data-tooltip={$t(kind === "file" ? "media.removeFile" : "media.removeImage")} onclick={() => onpick?.("", "")}><Icon name="x" size={14} /></button>{/if}
|
|
231
238
|
</div>
|
|
232
239
|
{/if}
|
|
233
240
|
|
|
@@ -235,11 +242,11 @@
|
|
|
235
242
|
<p class="hint">{$t(writable ? "media.repositoryWritable" : "media.repositorySnapshot", { folder: folder || "…" })}</p>
|
|
236
243
|
<div class="media-tools">
|
|
237
244
|
<div class="media-views" role="group" aria-label={$t("media.view")}>
|
|
238
|
-
<button type="button" class="outlined" data-tooltip={$t("media.gridView")} aria-label={$t("media.gridView")} aria-pressed={view === "grid"} onclick={() => setView("grid")}><Icon name="squaresFour" size={18} /></button>
|
|
239
|
-
<button type="button" class="outlined" data-tooltip={$t("media.listView")} aria-label={$t("media.listView")} aria-pressed={view === "list"} onclick={() => setView("list")}><Icon name="listBullets" size={18} /></button>
|
|
245
|
+
<button type="button" class="outlined icon-only" data-tooltip={$t("media.gridView")} aria-label={$t("media.gridView")} aria-pressed={view === "grid"} onclick={() => setView("grid")}><Icon name="squaresFour" size={18} /></button>
|
|
246
|
+
<button type="button" class="outlined icon-only" data-tooltip={$t("media.listView")} aria-label={$t("media.listView")} aria-pressed={view === "list"} onclick={() => setView("list")}><Icon name="listBullets" size={18} /></button>
|
|
240
247
|
</div>
|
|
241
248
|
<div class="media-select">
|
|
242
|
-
<Select ariaLabel={$t(inline ? "media.type" : kind === "video" ? "media.videoFormat" : "media.imageFormat")} value={format} onchange={next => { format = String(next); }} options={inline ? [{ value: "", label: $t("media.allItems") }, ...MEDIA_CATEGORIES.map(category => ({ value: category, label: $t(`media.category.${category}`) }))] : kind === "video" ? [{ value: "", label: $t("media.allVideos") }, { value: "video/mp4", label: "MP4" }, { value: "video/webm", label: "WebM" }, { value: "video/quicktime", label: "MOV" }] : [{ value: "", label: $t("media.allImages") }, ...["jpeg", "png", "webp", "gif", "avif"].map(format => ({ value: `image/${format}`, label: format === "jpeg" ? "JPEG" : format === "webp" ? "WebP" : format.toUpperCase() }))]} />
|
|
249
|
+
<Select ariaLabel={$t(inline || kind === "file" ? "media.type" : kind === "video" ? "media.videoFormat" : "media.imageFormat")} value={format} onchange={next => { format = String(next); }} options={inline || kind === "file" ? [{ value: "", label: $t("media.allItems") }, ...MEDIA_CATEGORIES.map(category => ({ value: category, label: $t(`media.category.${category}`) }))] : kind === "video" ? [{ value: "", label: $t("media.allVideos") }, { value: "video/mp4", label: "MP4" }, { value: "video/webm", label: "WebM" }, { value: "video/quicktime", label: "MOV" }] : [{ value: "", label: $t("media.allImages") }, ...["jpeg", "png", "webp", "gif", "avif"].map(format => ({ value: `image/${format}`, label: format === "jpeg" ? "JPEG" : format === "webp" ? "WebP" : format.toUpperCase() }))]} />
|
|
243
250
|
</div>
|
|
244
251
|
<div class="media-select">
|
|
245
252
|
<Select ariaLabel={$t("media.modifiedMonth")} data-tooltip={$t("media.modifiedMonthHint")} value={month} onchange={next => { month = String(next); }} options={[{ value: "", label: $t("media.allModifiedDates") }, ...months.map(date => ({ value: date, label: new Date(date + "-01T00:00:00Z").toLocaleDateString($locale, { month: "long", year: "numeric", timeZone: "UTC" }) }))]} />
|
|
@@ -261,9 +268,9 @@
|
|
|
261
268
|
{/snippet}
|
|
262
269
|
|
|
263
270
|
{#if open}
|
|
264
|
-
<dialog bind:this={dialog} class="rigel-modal-backdrop rigel image-dialog" class:media-insert={providerTab} aria-label={$t(providerTab ? editing ? "media.editVideo" : "media.addVideoLink" : inline ? "media.uploadMedia" : kind === "video" ? "media.chooseVideo" : "media.chooseImage")} oncancel={e => { e.preventDefault(); if (!picking) close(); }} onclick={e => { if (e.target === dialog && !picking) close(); }}>
|
|
271
|
+
<dialog bind:this={dialog} class="rigel-modal-backdrop rigel image-dialog" class:media-insert={providerTab} aria-label={$t(providerTab ? editing ? "media.editVideo" : "media.addVideoLink" : inline ? "media.uploadMedia" : kind === "file" ? "media.chooseFile" : kind === "video" ? "media.chooseVideo" : "media.chooseImage")} oncancel={e => { e.preventDefault(); if (!picking) close(); }} onclick={e => { if (e.target === dialog && !picking) close(); }}>
|
|
265
272
|
<div class="rigel-modal image-picker">
|
|
266
|
-
<div class="media-head"><h2>{providerTab ? $t(editing ? "media.editProviderVideo" : "media.addProviderVideo", { provider: tab === "youtube" ? "YouTube" : "Vimeo" }) : $t(selected ? "media.previewMedia" : inline ? "media.uploadMedia" : kind === "video" ? "media.chooseVideo" : "media.chooseImage")}</h2><button bind:this={cancel} type="button" class="close" aria-label={$t("media.close")} disabled={picking} onclick={close}><Icon name="x" size={14} weight="bold" /></button></div>
|
|
273
|
+
<div class="media-head"><h2>{providerTab ? $t(editing ? "media.editProviderVideo" : "media.addProviderVideo", { provider: tab === "youtube" ? "YouTube" : "Vimeo" }) : $t(selected ? "media.previewMedia" : inline ? "media.uploadMedia" : kind === "file" ? "media.chooseFile" : kind === "video" ? "media.chooseVideo" : "media.chooseImage")}</h2><button bind:this={cancel} type="button" class="close" aria-label={$t("media.close")} disabled={picking} onclick={close}><Icon name="x" size={14} weight="bold" /></button></div>
|
|
267
274
|
{#if selected}
|
|
268
275
|
<div class="media-selection" class:media-editing={(inline && !!selected.asset) || metadataImport}>
|
|
269
276
|
<div class="media-preview">
|
|
@@ -292,7 +299,7 @@
|
|
|
292
299
|
<label><span>{$t(kind === "video" ? "media.videoUrl" : "media.imageUrl")}</span><input type="text" readonly value={selected.url} /></label>
|
|
293
300
|
{/if}
|
|
294
301
|
{#if imageImport && !selected.file}<label><span>{$t("media.imageUrl")}</span><input type="text" readonly value={selected.url} /></label>{/if}
|
|
295
|
-
{#if !inline && !imageImport}<label><span>{$t(kind === "video" ? "media.captionOptional" : "media.descriptiveAlt")}</span><input aria-label={kind === "video" ? $t("media.videoCaption") : undefined} type="text" bind:value={alt} /></label>{/if}
|
|
302
|
+
{#if !inline && kind !== "file" && !imageImport}<label><span>{$t(kind === "video" ? "media.captionOptional" : "media.descriptiveAlt")}</span><input aria-label={kind === "video" ? $t("media.videoCaption") : undefined} type="text" bind:value={alt} /></label>{/if}
|
|
296
303
|
{#if kind === "video"}
|
|
297
304
|
<label class="inline"><input type="checkbox" role="switch" class="switch" aria-label={$t("media.muted")} bind:checked={muted} /><span>{$t("media.muted")}</span></label>
|
|
298
305
|
<label class="inline"><input type="checkbox" role="switch" class="switch" aria-label={$t("media.showControls")} bind:checked={controls} /><span>{$t("media.showControls")}</span></label>
|
|
@@ -313,12 +320,12 @@
|
|
|
313
320
|
<button type="button" class="primary" disabled={picking || savingMeta} onclick={saveMetadata}>{$t(savingMeta ? "media.saving" : "media.save")}</button>
|
|
314
321
|
{:else}
|
|
315
322
|
<button type="button" class="outlined" disabled={picking} onclick={() => { selected = null; releaseFile(); }}>{$t("media.back")}</button>
|
|
316
|
-
{#if inline && tab === "library"}<button type="button" class="primary" onclick={close}>{$t("media.done")}</button>{:else}<button type="button" class="primary" disabled={picking || (needsImport && !writable)} onclick={confirm}>{$t(picking ? kind === "video" ? "media.uploadingVideo" : "media.importingPhoto" : kind === "video" ? "media.useVideo" : !inline ? "media.insertImage" : selected.file && mediaMetadata(selected.file.name)?.category !== "Images" ? "media.uploadFile" : "media.importImage")}</button>{/if}
|
|
323
|
+
{#if inline && tab === "library"}<button type="button" class="primary" onclick={close}>{$t("media.done")}</button>{:else}<button type="button" class="primary" disabled={picking || (needsImport && !writable)} onclick={confirm}>{$t(picking ? kind === "file" ? "media.uploadingFile" : kind === "video" ? "media.uploadingVideo" : "media.importingPhoto" : kind === "file" ? selected.file ? "media.uploadFile" : "media.useFile" : kind === "video" ? "media.useVideo" : !inline ? "media.insertImage" : selected.file && mediaMetadata(selected.file.name)?.category !== "Images" ? "media.uploadFile" : "media.importImage")}</button>{/if}
|
|
317
324
|
{/if}</div>
|
|
318
325
|
</div>
|
|
319
326
|
</div>
|
|
320
327
|
{:else}
|
|
321
|
-
<div class="tabs" aria-label={$t(kind === "video" ? "media.videoSources" : "media.imageSources")}>
|
|
328
|
+
<div class="tabs" aria-label={$t(kind === "file" ? "media.fileSources" : kind === "video" ? "media.videoSources" : "media.imageSources")}>
|
|
322
329
|
{#each tabs.filter(source => !inline || (source !== "library" && source !== "unsplash")) as source}<button type="button" data-source={source} class:active={tab === source} aria-pressed={tab === source} disabled={picking} onclick={() => changeTab(source)}>{source === "youtube" ? "YouTube" : source === "vimeo" ? "Vimeo" : source === "library" || source === "upload" || source === "url" ? $t(`media.source.${source}`) : source}</button>{/each}
|
|
323
330
|
</div>
|
|
324
331
|
{#if inline}<p class="hint">{$t("media.unsplashUploadsHint")}</p>{/if}
|
|
@@ -335,7 +342,8 @@
|
|
|
335
342
|
{@render libraryBrowser()}
|
|
336
343
|
{:else if tab === "upload"}
|
|
337
344
|
<p class="hint">{$t("media.localFileHint")}</p>
|
|
338
|
-
{#if
|
|
345
|
+
{#if kind === "file" && accept}<p class="hint">{$t("media.chooseAcceptedFile", { accept })}</p>{/if}
|
|
346
|
+
{#if writable}<label><span>{$t(kind === "video" ? "media.uploadVideoLimit" : inline || kind === "file" ? "media.uploadMediaLimit" : "media.uploadImageLimit", { limit: maxFileSize / 1024 / 1024 })}</span><input type="file" accept={kind === "file" ? accept ?? mediaAccept() : kind === "video" ? ".mp4,.webm,.mov" : mediaAccept(!inline)} onchange={e => upload(e.currentTarget.files?.[0])} /></label>{:else}<p role="status">{$t("media.readOnlyUploads")}</p>{/if}
|
|
339
347
|
{:else if tab === "url"}
|
|
340
348
|
<label><span>{$t(kind === "video" ? "media.videoUrl" : "media.imageUrl")}</span><input type="url" bind:value={urlInput} placeholder={$t(kind === "video" ? "media.videoUrlPlaceholder" : "media.imageUrlPlaceholder")} /></label>
|
|
341
349
|
<button type="button" class="primary" onclick={run}>{$t("media.useUrl")}</button>
|
|
@@ -86,10 +86,10 @@
|
|
|
86
86
|
</label>
|
|
87
87
|
{:else if field.type === "markdown"}
|
|
88
88
|
<MarkdownEditor label={fieldLabel} value={typeof value === "string" ? value : ""} oninput={(next) => onvalue(next)} />
|
|
89
|
-
{:else if field.type === "image"}
|
|
89
|
+
{:else if field.type === "image" || field.type === "file"}
|
|
90
90
|
<div class="schema-field">
|
|
91
91
|
<span class="schema-label">{label}</span>
|
|
92
|
-
<ImagePicker value={typeof value === "string" ? value : ""} onpick={(url) => onvalue(url)} />
|
|
92
|
+
<ImagePicker kind={field.type} accept={field.accept} value={typeof value === "string" ? value : ""} onpick={(url) => onvalue(url)} />
|
|
93
93
|
</div>
|
|
94
94
|
{:else if field.type === "boolean"}
|
|
95
95
|
<label class="inline">
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import PropertiesTabs from "./PropertiesTabs.svelte";
|
|
6
6
|
import SeoGuidance from "./SeoGuidance.svelte";
|
|
7
7
|
import Select from "./Select.svelte";
|
|
8
|
-
import { tick } from "svelte";
|
|
8
|
+
import { onDestroy, tick } from "svelte";
|
|
9
9
|
import {
|
|
10
10
|
blankItem,
|
|
11
11
|
duplicateItem,
|
|
@@ -89,8 +89,79 @@
|
|
|
89
89
|
function removeSection(index: number) {
|
|
90
90
|
setSections(sections.filter((_, position) => position !== index));
|
|
91
91
|
}
|
|
92
|
+
|
|
93
|
+
let dragging = $state<string | null>(null);
|
|
94
|
+
let target = $state<number | null>(null);
|
|
95
|
+
let message = $state("");
|
|
96
|
+
let pointer: { key: string; docId: string; pointerId: number; x: number; y: number; handle: HTMLButtonElement } | null = null;
|
|
97
|
+
|
|
98
|
+
function cancelDrag() {
|
|
99
|
+
const active = pointer;
|
|
100
|
+
pointer = null;
|
|
101
|
+
dragging = null;
|
|
102
|
+
target = null;
|
|
103
|
+
if (active?.handle.hasPointerCapture(active.pointerId)) active.handle.releasePointerCapture(active.pointerId);
|
|
104
|
+
}
|
|
105
|
+
onDestroy(cancelDrag);
|
|
106
|
+
|
|
107
|
+
function startDrag(event: PointerEvent, key: string) {
|
|
108
|
+
if (event.button !== 0 || !event.isPrimary || pointer) return;
|
|
109
|
+
const handle = event.currentTarget as HTMLButtonElement;
|
|
110
|
+
pointer = { key, docId, pointerId: event.pointerId, x: event.clientX, y: event.clientY, handle };
|
|
111
|
+
handle.setPointerCapture(event.pointerId);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function dropAt(x: number, y: number) {
|
|
115
|
+
const list = document.elementFromPoint(x, y)?.closest('.structure-list');
|
|
116
|
+
const source = pointer?.handle.closest('li');
|
|
117
|
+
if (!list || source?.parentElement !== list || pointer?.docId !== docId) return null;
|
|
118
|
+
const rows = [...list.children];
|
|
119
|
+
const before = rows.findIndex(row => { const rect = row.getBoundingClientRect(); return y < rect.top + rect.height / 2; });
|
|
120
|
+
const insertion = before < 0 ? rows.length : before;
|
|
121
|
+
const from = sections.findIndex(section => section._key === pointer?.key);
|
|
122
|
+
return from < 0 || insertion === from || insertion === from + 1 ? null : insertion;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function dragPointer(event: PointerEvent) {
|
|
126
|
+
if (!pointer || event.pointerId !== pointer.pointerId) return;
|
|
127
|
+
if (!dragging && Math.hypot(event.clientX - pointer.x, event.clientY - pointer.y) < 5) return;
|
|
128
|
+
dragging = pointer.key;
|
|
129
|
+
target = dropAt(event.clientX, event.clientY);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function finishDrag(event: PointerEvent) {
|
|
133
|
+
if (!pointer || event.pointerId !== pointer.pointerId) return;
|
|
134
|
+
const captured = pointer.handle.hasPointerCapture(event.pointerId);
|
|
135
|
+
const from = captured ? sections.findIndex(section => section._key === dragging) : -1;
|
|
136
|
+
const insertion = dropAt(event.clientX, event.clientY);
|
|
137
|
+
cancelDrag();
|
|
138
|
+
if (from < 0 || insertion === null) return;
|
|
139
|
+
const next = [...sections];
|
|
140
|
+
const [section] = next.splice(from, 1);
|
|
141
|
+
const index = insertion > from ? insertion - 1 : insertion;
|
|
142
|
+
next.splice(index, 0, section);
|
|
143
|
+
setSections(next);
|
|
144
|
+
message = $t("fields.sectionMoved", { label: sectionLabel(schema, section._type), position: index + 1, count: next.length });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function moveWithKeyboard(event: KeyboardEvent, index: number) {
|
|
148
|
+
if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return;
|
|
149
|
+
event.preventDefault();
|
|
150
|
+
if (pointer) return;
|
|
151
|
+
const delta = event.key === "ArrowUp" ? -1 : 1;
|
|
152
|
+
const position = index + delta;
|
|
153
|
+
if (position < 0 || position >= sections.length) return;
|
|
154
|
+
const handle = event.currentTarget as HTMLButtonElement;
|
|
155
|
+
const label = sectionLabel(schema, sections[index]._type);
|
|
156
|
+
setSections(moveItem(sections, index, delta) as Record<string, unknown>[]);
|
|
157
|
+
message = $t("fields.sectionMoved", { label, position: position + 1, count: sections.length });
|
|
158
|
+
await tick();
|
|
159
|
+
handle.focus({ preventScroll: true });
|
|
160
|
+
}
|
|
92
161
|
</script>
|
|
93
162
|
|
|
163
|
+
<svelte:window onpointermove={dragPointer} onpointerup={finishDrag} onpointercancel={cancelDrag} onkeydown={event => { if (event.key === 'Escape' && pointer) { event.preventDefault(); cancelDrag(); message = $t('fields.menuMoveCancelled'); } }} />
|
|
164
|
+
|
|
94
165
|
<div class="schema-panel" bind:this={host}>
|
|
95
166
|
{#if docType.shared}
|
|
96
167
|
<p class="shared-badge"><Icon name="info" size={12} /> {$t(docType.demo ? "fields.demoContentNotice" : "fields.sharedContentNotice")}</p>
|
|
@@ -115,16 +186,17 @@
|
|
|
115
186
|
{#if sections.length || values.sections !== undefined}
|
|
116
187
|
<section>
|
|
117
188
|
<h3>{$t("fields.structure")}</h3>
|
|
189
|
+
<p id="section-reorder-hint" class="reorder-status">{$t("fields.sectionReorderHint")}</p>
|
|
190
|
+
<p class="reorder-status" role="status" aria-live="polite">{message}</p>
|
|
118
191
|
<ul class="structure-list">
|
|
119
192
|
{#each sections as section, index (section._key)}
|
|
120
|
-
<li class:active={selected?.sectionKey === section._key}>
|
|
193
|
+
<li class:active={selected?.sectionKey === section._key} class:is-dragging={dragging === section._key} class:drop-before={target === index} class:drop-after={target === sections.length && index === sections.length - 1} data-section-key={section._key}>
|
|
194
|
+
<button type="button" class="outlined icon-only section-drag" aria-label={$t("fields.reorderSection", { label: sectionLabel(schema, section._type) })} aria-describedby="section-reorder-hint" onpointerdown={event => startDrag(event, String(section._key))} onlostpointercapture={cancelDrag} onkeydown={event => moveWithKeyboard(event, index)}><Icon name="dotsSixVertical" /></button>
|
|
121
195
|
<button type="button" class="structure-row" onclick={() => onselect({ docId, path: "", sectionKey: String(section._key) })}>
|
|
122
196
|
<span>{sectionLabel(schema, section._type, $t("fields.section"))}</span>
|
|
123
197
|
{#if devMode}<span class="schema-key">{section._key}</span>{/if}
|
|
124
198
|
</button>
|
|
125
199
|
<div class="structure-actions">
|
|
126
|
-
<button type="button" aria-label={$t("fields.moveSectionUp", { label: sectionLabel(schema, section._type) })} data-tooltip={$t("fields.moveUp")} disabled={index === 0} onclick={() => setSections(moveItem(sections, index, -1) as Record<string, unknown>[])}>↑</button>
|
|
127
|
-
<button type="button" aria-label={$t("fields.moveSectionDown", { label: sectionLabel(schema, section._type) })} data-tooltip={$t("fields.moveDown")} disabled={index === sections.length - 1} onclick={() => setSections(moveItem(sections, index, 1) as Record<string, unknown>[])}>↓</button>
|
|
128
200
|
<button type="button" aria-label={$t("fields.duplicateSection", { label: sectionLabel(schema, section._type) })} data-tooltip={$t("fields.duplicate")} onclick={() => setSections(duplicateItem(sections, index) as Record<string, unknown>[])}>⧉</button>
|
|
129
201
|
<button type="button" aria-label={$t("fields.removeSection", { label: sectionLabel(schema, section._type) })} data-tooltip={$t("fields.remove")} onclick={() => removeSection(index)}>×</button>
|
|
130
202
|
</div>
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { t } from "../i18n";
|
|
3
3
|
import { mount, unmount, onDestroy, tick, type Snippet } from "svelte";
|
|
4
4
|
import BlockEditor from "./BlockEditor.svelte";
|
|
5
|
+
import MarkdownEditor from "./MarkdownEditor.svelte";
|
|
5
6
|
import { tooltipRoot } from "./tooltip";
|
|
6
7
|
import editorStyles from "../styles.css?inline";
|
|
7
8
|
import type { ArticleBody } from "../../article-body";
|
|
@@ -248,11 +249,40 @@
|
|
|
248
249
|
}
|
|
249
250
|
if (editVideo) mounted.editVideoAt(blockIndex);
|
|
250
251
|
}
|
|
252
|
+
function startMarkdownEdit(element: HTMLElement) {
|
|
253
|
+
const field = fieldPathFrom(element);
|
|
254
|
+
if (!field || field.doc !== docId || !schema || !docType) return false;
|
|
255
|
+
const def = fieldForPath(schema, docType, values, field.path);
|
|
256
|
+
if (def?.type !== "markdown") return false;
|
|
257
|
+
stopBodyEdit();
|
|
258
|
+
const doc = element.ownerDocument;
|
|
259
|
+
let editorStyle = doc.querySelector<HTMLStyleElement>('style[data-rigel-editor-styles]');
|
|
260
|
+
if (!editorStyle) { editorStyle = doc.createElement("style"); editorStyle.dataset.rigelEditorStyles = ""; doc.head.append(editorStyle); }
|
|
261
|
+
if (editorStyle.textContent !== editorStyles) editorStyle.textContent = editorStyles;
|
|
262
|
+
clearHover(); clearHighlight();
|
|
263
|
+
editingBody = element; bodyContents = [...element.childNodes];
|
|
264
|
+
const scope = doc.createElement("div");
|
|
265
|
+
const syncTheme = () => { scope.dataset.theme = document.documentElement.dataset.theme; scope.dataset.contrast = document.documentElement.dataset.contrast; };
|
|
266
|
+
syncTheme();
|
|
267
|
+
const host = doc.createElement("div"); host.className = "rigel rigel-inline";
|
|
268
|
+
scope.append(host); element.replaceChildren(scope); bodyScope = scope;
|
|
269
|
+
removeTooltips = tooltipRoot(host).destroy;
|
|
270
|
+
bodyEditor = mount(MarkdownEditor, { target: host, props: {
|
|
271
|
+
label: def.labelKey ? $t(def.labelKey) : def.label,
|
|
272
|
+
get value() { return String(anchorValue(values, field.path).value ?? ""); },
|
|
273
|
+
oninput: (next: string) => onchange(replaceAt(values, field.path, next)),
|
|
274
|
+
} });
|
|
275
|
+
bodyThemeObserver = new MutationObserver(syncTheme);
|
|
276
|
+
bodyThemeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme", "data-contrast"] });
|
|
277
|
+
void tick().then(() => host.querySelector<HTMLElement>('[contenteditable="true"], textarea')?.focus());
|
|
278
|
+
return true;
|
|
279
|
+
}
|
|
280
|
+
|
|
251
281
|
function focusBody() {
|
|
252
282
|
const target = docElement()?.querySelector<HTMLElement>('[data-rigel-richtext="$body"]');
|
|
253
283
|
if (inlineBodyEnabled && target) { target.scrollIntoView({ block: "center" }); void startBodyEdit(target); }
|
|
254
284
|
}
|
|
255
|
-
$effect(() => { if (!inlineBodyEnabled || mode !== "edit") stopBodyEdit(); });
|
|
285
|
+
$effect(() => { if ((!schema && !inlineBodyEnabled) || mode !== "edit") stopBodyEdit(); });
|
|
256
286
|
onDestroy(stopBodyEdit);
|
|
257
287
|
|
|
258
288
|
function onClick(event: MouseEvent) {
|
|
@@ -264,6 +294,9 @@
|
|
|
264
294
|
if (!target) return;
|
|
265
295
|
const articleBody = target.closest<HTMLElement>('[data-rigel-richtext="$body"]');
|
|
266
296
|
if (inlineBodyEnabled && articleBody) { void startBodyEdit(articleBody, event); return; }
|
|
297
|
+
const markdown = target.closest<HTMLElement>("[data-rigel-richtext]");
|
|
298
|
+
if (markdown && startMarkdownEdit(markdown)) return;
|
|
299
|
+
stopBodyEdit();
|
|
267
300
|
const element = target.closest<HTMLElement>("[data-rigel-tags],[data-rigel-text],[data-rigel-richtext],[data-rigel-image],[data-rigel-alt],[data-rigel-href],[data-rigel-section]");
|
|
268
301
|
if (element) selectFromElement(element);
|
|
269
302
|
}
|
package/src/editor/lib/icons.ts
CHANGED
|
@@ -78,6 +78,10 @@ export const ICONS = {
|
|
|
78
78
|
viewBox: "0 0 256 256",
|
|
79
79
|
d: "M128,96a32,32,0,1,0,32,32A32,32,0,0,0,128,96Zm0,48a16,16,0,1,1,16-16A16,16,0,0,1,128,144Zm0-64A32,32,0,1,0,96,48,32,32,0,0,0,128,80Zm0-48a16,16,0,1,1-16,16A16,16,0,0,1,128,32Zm0,144a32,32,0,1,0,32,32A32,32,0,0,0,128,176Zm0,48a16,16,0,1,1,16-16A16,16,0,0,1,128,224Z",
|
|
80
80
|
},
|
|
81
|
+
dotsSixVertical: {
|
|
82
|
+
viewBox: "0 0 256 256",
|
|
83
|
+
d: "M104,60A12,12,0,1,1,92,48,12,12,0,0,1,104,60Zm60,12a12,12,0,1,0-12-12A12,12,0,0,0,164,72ZM92,116a12,12,0,1,0,12,12A12,12,0,0,0,92,116Zm72,0a12,12,0,1,0,12,12A12,12,0,0,0,164,116ZM92,184a12,12,0,1,0,12,12A12,12,0,0,0,92,184Zm72,0a12,12,0,1,0,12,12A12,12,0,0,0,164,184Z",
|
|
84
|
+
},
|
|
81
85
|
keyboard: {
|
|
82
86
|
viewBox: "0 0 256 256",
|
|
83
87
|
d: "M224,48H32A16,16,0,0,0,16,64V192a16,16,0,0,0,16,16H224a16,16,0,0,0,16-16V64A16,16,0,0,0,224,48Zm0,144H32V64H224V192Zm-16-64a8,8,0,0,1-8,8H56a8,8,0,0,1,0-16H200A8,8,0,0,1,208,128Zm0-32a8,8,0,0,1-8,8H56a8,8,0,0,1,0-16H200A8,8,0,0,1,208,96ZM72,160a8,8,0,0,1-8,8H56a8,8,0,0,1,0-16h8A8,8,0,0,1,72,160Zm96,0a8,8,0,0,1-8,8H96a8,8,0,0,1,0-16h64A8,8,0,0,1,168,160Zm40,0a8,8,0,0,1-8,8h-8a8,8,0,0,1,0-16h8A8,8,0,0,1,208,160Z",
|
package/src/editor/styles.css
CHANGED
|
@@ -849,7 +849,8 @@
|
|
|
849
849
|
.rigel .media-head h2 { margin: 0; font-size: 1rem; }
|
|
850
850
|
.rigel .media-tools { align-items: center; flex-wrap: wrap; gap: 0.5rem; padding: 0.75rem; border: 1px solid var(--border-control); background: var(--panel); }
|
|
851
851
|
.rigel .media-views { display: flex; flex-shrink: 0; margin-inline-end: 1rem; }
|
|
852
|
-
.rigel .
|
|
852
|
+
.rigel button.outlined.icon-only, .rigel :is(.structure-actions, .schema-item-actions) button { display: inline-flex; align-items: center; justify-content: center; width: 2.25rem; height: 2.25rem; padding: 0; flex-shrink: 0; }
|
|
853
|
+
@media (pointer: coarse) { .rigel button.outlined.icon-only, .rigel :is(.structure-actions, .schema-item-actions) button { width: 44px; height: 44px; } }
|
|
853
854
|
.rigel .media-views button + button { border-left: 0; }
|
|
854
855
|
.rigel .media-views button[aria-pressed=true] { background: var(--accent-soft); color: var(--on-accent-soft); }
|
|
855
856
|
.rigel .media-tools .media-select { position: relative; max-width: 100%; min-width: 0; }
|
|
@@ -1305,11 +1306,18 @@
|
|
|
1305
1306
|
.rigel .panel-note { margin: 0 0 0.75rem; font-size: 0.75rem; color: var(--text-3); }
|
|
1306
1307
|
.rigel .shared-badge { display: flex; align-items: center; gap: 0.375rem; margin: 0 0 0.75rem; padding: 0.5rem; border: 1px solid var(--accent); color: var(--text-2); font-size: 0.75rem; }
|
|
1307
1308
|
.rigel .structure-list { list-style: none; margin: 0 0 0.75rem; padding: 0; display: flex; flex-direction: column; gap: 0.375rem; }
|
|
1308
|
-
.rigel .structure-list li { display: flex; align-items: stretch; border: 1px solid var(--border); }
|
|
1309
|
+
.rigel .structure-list li { position: relative; display: flex; align-items: stretch; border: 1px solid var(--border); }
|
|
1310
|
+
.rigel .structure-list .section-drag { cursor: grab; touch-action: none; user-select: none; align-self: center; border: 0; background: transparent; }
|
|
1311
|
+
.rigel .structure-list .section-drag:active { cursor: grabbing; }
|
|
1312
|
+
.rigel .structure-list li.is-dragging { border-style: dashed; }
|
|
1313
|
+
.rigel .structure-list li.drop-before::before, .rigel .structure-list li.drop-after::after { content: ""; position: absolute; left: -1px; right: -1px; height: 3px; background: var(--accent); pointer-events: none; }
|
|
1314
|
+
.rigel .structure-list li.drop-before::before { top: -5px; }
|
|
1315
|
+
.rigel .structure-list li.drop-after::after { bottom: -5px; }
|
|
1316
|
+
.rigel .reorder-status { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
|
1309
1317
|
.rigel .structure-list li.active { border-color: var(--accent); }
|
|
1310
1318
|
.rigel .structure-row { flex: 1; min-width: 0; display: flex; justify-content: space-between; gap: 0.5rem; align-items: center; border: 0; border-radius: 0; background: transparent; color: var(--text); padding: 0.5rem; text-align: left; cursor: pointer; font-size: 0.8125rem; }
|
|
1311
1319
|
.rigel .structure-actions, .rigel .schema-item-actions { display: flex; align-items: center; }
|
|
1312
|
-
.rigel .structure-actions button, .rigel .schema-item-actions button { border: 0; border-left: 1px solid var(--border); border-radius: 0; background: transparent; color: var(--text-2);
|
|
1320
|
+
.rigel .structure-actions button, .rigel .schema-item-actions button { border: 0; border-left: 1px solid var(--border); border-radius: 0; background: transparent; color: var(--text-2); cursor: pointer; }
|
|
1313
1321
|
.rigel .structure-actions button:disabled, .rigel .schema-item-actions button:disabled { opacity: 0.35; cursor: default; }
|
|
1314
1322
|
.rigel .add-section { display: flex; gap: 0.5rem; }
|
|
1315
1323
|
.rigel .add-section select { flex: 1; min-width: 0; }
|
|
@@ -35,7 +35,7 @@ export function selectionInContent(manifest: Manifest, selection: Selection | nu
|
|
|
35
35
|
if (!selection) return true;
|
|
36
36
|
switch (selection.kind) {
|
|
37
37
|
case "page": return manifest.pages.some(page => page.slug === selection.slug);
|
|
38
|
-
case "pages": return !selection.category || manifest.pages.some(page => !page.shared && page.category === selection.category);
|
|
38
|
+
case "pages": return !selection.category || manifest.pages.some(page => !page.shared && page.category === selection.category) || !!manifest.schema?.documents.some(type => !type.shared && type.category === selection.category);
|
|
39
39
|
case "document":
|
|
40
40
|
case "documents": return !!manifest.schema?.documents.some(type => type.name === selection.type);
|
|
41
41
|
case "post": return !!manifest.collections?.some(collection => collection.name === selection.collection);
|
|
@@ -32,7 +32,7 @@ export function buildSidebar(manifest: Manifest, message: Translator = systemMes
|
|
|
32
32
|
const link = (target: SidebarTarget, label: string): SidebarLink => ({ kind: "link", target, label });
|
|
33
33
|
const types = manifest.schema?.documents ?? [];
|
|
34
34
|
const pages = manifest.pages.filter(page => !page.shared);
|
|
35
|
-
const categories = [...new Set(pages.map(
|
|
35
|
+
const categories = [...new Set([...pages, ...types.filter(type => !type.shared)].map(entry => entry.category).filter((category): category is string => !!category))].sort();
|
|
36
36
|
const defaults: SidebarNode[] = [
|
|
37
37
|
{ kind: "heading", label: message("system.sidebar.website") },
|
|
38
38
|
link({ kind: "pages" }, message("system.sidebar.pages")),
|
|
@@ -72,7 +72,8 @@ export function buildSidebar(manifest: Manifest, message: Translator = systemMes
|
|
|
72
72
|
used.add(key);
|
|
73
73
|
nodes.push({ ...item, label: entry.label ?? item.label, ...(entry.icon ? { icon: entry.icon } : {}) });
|
|
74
74
|
}
|
|
75
|
-
const
|
|
75
|
+
const allPages = used.has(navigationKey({ kind: "pages" }));
|
|
76
|
+
const remaining = defaults.filter((node): node is SidebarLink => node.kind === "link" && !used.has(navigationKey(node.target)) && !(allPages && node.target.kind === "pages" && node.target.category));
|
|
76
77
|
if (remaining.length) nodes.push({ kind: "heading", label: message("system.sidebar.more") }, ...remaining);
|
|
77
78
|
return nodes;
|
|
78
79
|
}
|
package/src/media-types.ts
CHANGED
|
@@ -32,3 +32,20 @@ export function safeMediaName(name: string) {
|
|
|
32
32
|
export function mediaAccept(imagesOnly = false) {
|
|
33
33
|
return Object.keys(MEDIA_TYPES).filter(ext => !imagesOnly || MEDIA_TYPES[ext].category === 'Images').map(ext => '.' + ext).join(',');
|
|
34
34
|
}
|
|
35
|
+
|
|
36
|
+
export function matchesMediaAccept(name: string, accept?: string): boolean {
|
|
37
|
+
const metadata = mediaMetadata(name);
|
|
38
|
+
if (!metadata) return false;
|
|
39
|
+
return !accept || accept.toLowerCase().split(',').some(token => {
|
|
40
|
+
const spec = token.trim();
|
|
41
|
+
return spec === '.' + metadata.extension || spec === metadata.type || (spec.endsWith('/*') && metadata.type.startsWith(spec.slice(0, -1)));
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function safeFileUrl(value: string): boolean {
|
|
46
|
+
if (!value.startsWith('/') || value.startsWith('//') || /[\s\u0000-\u001f\u007f\\?#]/.test(value)) return false;
|
|
47
|
+
try {
|
|
48
|
+
const parts = value.slice(1).split('/').map(part => decodeURIComponent(part));
|
|
49
|
+
return parts.every(part => !!part && part !== '.' && part !== '..' && !/[\u0000-\u001f\u007f/\\?#]/.test(part)) && !!mediaMetadata(parts.at(-1)!);
|
|
50
|
+
} catch { return false; }
|
|
51
|
+
}
|
package/src/schema/types.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type ScalarFieldType = "string" | "text" | "markdown" | "boolean" | "number" | "date" | "image" | "url" | "color" | "code" | "password";
|
|
1
|
+
export type ScalarFieldType = "string" | "text" | "markdown" | "boolean" | "number" | "date" | "image" | "file" | "url" | "color" | "code" | "password";
|
|
2
2
|
|
|
3
3
|
export type ArrayItem =
|
|
4
4
|
| { type: "object"; name?: string; title?: string; fields?: FieldDef[] }
|
|
@@ -11,6 +11,7 @@ export interface FieldDef {
|
|
|
11
11
|
labelKey?: string;
|
|
12
12
|
type: ScalarFieldType | "object" | "array" | "reference";
|
|
13
13
|
placeholder?: string;
|
|
14
|
+
accept?: string;
|
|
14
15
|
required?: boolean;
|
|
15
16
|
initialValue?: unknown;
|
|
16
17
|
fields?: FieldDef[];
|
|
@@ -41,6 +42,7 @@ export interface DocumentDef {
|
|
|
41
42
|
folder: string;
|
|
42
43
|
fields: FieldDef[];
|
|
43
44
|
columns?: string[];
|
|
45
|
+
category?: string;
|
|
44
46
|
shared?: boolean;
|
|
45
47
|
singleton?: boolean;
|
|
46
48
|
demo?: boolean;
|
package/src/schema/validate.ts
CHANGED
|
@@ -12,8 +12,9 @@ import {
|
|
|
12
12
|
type Schema,
|
|
13
13
|
} from "./types";
|
|
14
14
|
import { systemMessage, type Parameters, type Translator } from "../editor/i18n/format";
|
|
15
|
+
import { matchesMediaAccept, safeFileUrl } from "../media-types";
|
|
15
16
|
|
|
16
|
-
const SCALARS: ScalarFieldType[] = ["string", "text", "markdown", "boolean", "number", "date", "image", "url", "color", "code", "password"];
|
|
17
|
+
const SCALARS: ScalarFieldType[] = ["string", "text", "markdown", "boolean", "number", "date", "image", "file", "url", "color", "code", "password"];
|
|
17
18
|
const UNSAFE = new Set(["__proto__", "constructor", "prototype"]);
|
|
18
19
|
const FOLDER_PART = /^[a-zA-Z0-9_-][a-zA-Z0-9_. -]*$/;
|
|
19
20
|
|
|
@@ -54,6 +55,7 @@ export function validateSchema(input: Schema): Schema {
|
|
|
54
55
|
if (doc.demo !== undefined && typeof doc.demo !== "boolean") throw new SchemaError(`Document type ${doc.name}: demo must be boolean`);
|
|
55
56
|
if (doc.creatable !== undefined && typeof doc.creatable !== "boolean") throw new SchemaError(`Document type ${doc.name}: creatable must be boolean`);
|
|
56
57
|
if (typeof doc.title !== "string" || !doc.title.trim()) throw new SchemaError(`Document type ${doc.name} requires a title`);
|
|
58
|
+
if (doc.category !== undefined && (typeof doc.category !== "string" || !doc.category.trim())) throw new SchemaError(`Document type ${doc.name}: category must be a non-empty string`);
|
|
57
59
|
const parts = String(doc.folder).split("/");
|
|
58
60
|
if (!parts.length || parts.some((part) => !FOLDER_PART.test(part))) throw new SchemaError(`Document type ${doc.name} has an unsafe folder`);
|
|
59
61
|
if (folders.has(doc.folder)) throw new SchemaError(`Duplicate document folder: ${doc.folder}`);
|
|
@@ -88,6 +90,7 @@ function validateFieldsShape(schema: Schema, fields: FieldDef[], scope: string)
|
|
|
88
90
|
if (keys.has(field.key)) throw new SchemaError(`${scope}: duplicate field key ${field.key}`);
|
|
89
91
|
keys.add(field.key);
|
|
90
92
|
if (typeof field.label !== "string" || !field.label.trim()) throw new SchemaError(`${scope}.${field.key}: label required`);
|
|
93
|
+
if (field.accept !== undefined && (field.type !== "file" || typeof field.accept !== "string" || !field.accept.split(",").every(token => /^(?:\.[a-z0-9]+|[a-z0-9!#$&^_.+-]+\/(?:[a-z0-9!#$&^_.+-]+|\*))$/i.test(token.trim())))) throw new SchemaError(`${scope}.${field.key}: accept must contain file extensions or MIME types`);
|
|
91
94
|
if (![...SCALARS, "object", "array", "reference"].includes(field.type)) throw new SchemaError(`${scope}.${field.key}: unknown field type ${field.type}`);
|
|
92
95
|
if (field.type === "object") {
|
|
93
96
|
if (field.of) objectDef(schema, field.of);
|
|
@@ -130,6 +133,7 @@ function scalarOk(type: ScalarFieldType, value: unknown): boolean {
|
|
|
130
133
|
if (type === "boolean") return typeof value === "boolean";
|
|
131
134
|
if (type === "number") return typeof value === "number" && Number.isFinite(value);
|
|
132
135
|
if (typeof value !== "string") return false;
|
|
136
|
+
if (type === "file") return safeFileUrl(value);
|
|
133
137
|
if (type === "color") return value === "" || /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i.test(value);
|
|
134
138
|
if (type === "date") return /^\d{4}-\d{2}-\d{2}$/.test(value);
|
|
135
139
|
if (type === "image" || type === "url") return safeUrl(value);
|
|
@@ -138,7 +142,7 @@ function scalarOk(type: ScalarFieldType, value: unknown): boolean {
|
|
|
138
142
|
|
|
139
143
|
function requireValue(field: FieldDef, value: unknown, path: string, message: Translator) {
|
|
140
144
|
if (value === undefined || value === null) fail(path, "validation.schema.required", message);
|
|
141
|
-
if (typeof value === "string" && ["string", "text", "markdown", "image", "url"].includes(field.type) && !value.trim()) fail(path, "validation.schema.required", message);
|
|
145
|
+
if (typeof value === "string" && ["string", "text", "markdown", "image", "file", "url"].includes(field.type) && !value.trim()) fail(path, "validation.schema.required", message);
|
|
142
146
|
if (field.required && Array.isArray(value) && !value.length) fail(path, "validation.schema.required", message);
|
|
143
147
|
}
|
|
144
148
|
|
|
@@ -214,8 +218,9 @@ function validateFieldValue(schema: Schema, field: FieldDef, value: unknown, pat
|
|
|
214
218
|
validateReference(schema, value, field.to ?? [], path, documents, message);
|
|
215
219
|
return;
|
|
216
220
|
}
|
|
217
|
-
if ((field.type === "url" || field.type === "image") && value === "" && !field.required) return;
|
|
221
|
+
if ((field.type === "url" || field.type === "image" || field.type === "file") && value === "" && !field.required) return;
|
|
218
222
|
if (!scalarOk(field.type as ScalarFieldType, value)) fail(path, "validation.schema.scalar", message, { type: field.type });
|
|
223
|
+
if (field.type === "file" && !matchesMediaAccept(value as string, field.accept)) fail(path, "validation.schema.scalar", message, { type: field.accept ?? "file" });
|
|
219
224
|
}
|
|
220
225
|
|
|
221
226
|
type ItemDef = { type: ScalarFieldType | "object" | "reference"; name?: string; fields?: FieldDef[]; to?: string[] };
|