@rigel-cms/rigel 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/README.md +4 -0
  2. package/docs/extensions.md +194 -0
  3. package/package.json +15 -3
  4. package/src/article-body.ts +49 -37
  5. package/src/article-render.ts +25 -2
  6. package/src/article.ts +44 -0
  7. package/src/config.ts +12 -2
  8. package/src/editor/App.svelte +98 -97
  9. package/src/editor/blocks/extension.ts +29 -0
  10. package/src/editor/blocks/index.ts +17 -15
  11. package/src/editor/blocks/video.ts +2 -1
  12. package/src/editor/i18n/README.md +23 -0
  13. package/src/editor/i18n/de/connection.json +136 -0
  14. package/src/editor/i18n/de/defaults.json +6 -0
  15. package/src/editor/i18n/de/fields.json +130 -0
  16. package/src/editor/i18n/de/local.json +49 -0
  17. package/src/editor/i18n/de/media.json +166 -0
  18. package/src/editor/i18n/de/richtext.json +116 -0
  19. package/src/editor/i18n/de/shell.json +181 -0
  20. package/src/editor/i18n/de/system.json +14 -0
  21. package/src/editor/i18n/de/validation.json +82 -0
  22. package/src/editor/i18n/en/connection.json +136 -0
  23. package/src/editor/i18n/en/defaults.json +6 -0
  24. package/src/editor/i18n/en/fields.json +130 -0
  25. package/src/editor/i18n/en/local.json +49 -0
  26. package/src/editor/i18n/en/media.json +166 -0
  27. package/src/editor/i18n/en/richtext.json +116 -0
  28. package/src/editor/i18n/en/shell.json +181 -0
  29. package/src/editor/i18n/en/system.json +14 -0
  30. package/src/editor/i18n/en/validation.json +82 -0
  31. package/src/editor/i18n/es/connection.json +136 -0
  32. package/src/editor/i18n/es/defaults.json +6 -0
  33. package/src/editor/i18n/es/fields.json +130 -0
  34. package/src/editor/i18n/es/local.json +49 -0
  35. package/src/editor/i18n/es/media.json +166 -0
  36. package/src/editor/i18n/es/richtext.json +116 -0
  37. package/src/editor/i18n/es/shell.json +181 -0
  38. package/src/editor/i18n/es/system.json +14 -0
  39. package/src/editor/i18n/es/validation.json +82 -0
  40. package/src/editor/i18n/format.ts +26 -0
  41. package/src/editor/i18n/fr/connection.json +136 -0
  42. package/src/editor/i18n/fr/defaults.json +6 -0
  43. package/src/editor/i18n/fr/fields.json +130 -0
  44. package/src/editor/i18n/fr/local.json +49 -0
  45. package/src/editor/i18n/fr/media.json +166 -0
  46. package/src/editor/i18n/fr/richtext.json +116 -0
  47. package/src/editor/i18n/fr/shell.json +181 -0
  48. package/src/editor/i18n/fr/system.json +14 -0
  49. package/src/editor/i18n/fr/validation.json +82 -0
  50. package/src/editor/i18n/index.ts +18 -0
  51. package/src/editor/i18n/it/connection.json +136 -0
  52. package/src/editor/i18n/it/defaults.json +6 -0
  53. package/src/editor/i18n/it/fields.json +130 -0
  54. package/src/editor/i18n/it/local.json +49 -0
  55. package/src/editor/i18n/it/media.json +166 -0
  56. package/src/editor/i18n/it/richtext.json +116 -0
  57. package/src/editor/i18n/it/shell.json +181 -0
  58. package/src/editor/i18n/it/system.json +14 -0
  59. package/src/editor/i18n/it/validation.json +82 -0
  60. package/src/editor/i18n/ja/connection.json +136 -0
  61. package/src/editor/i18n/ja/defaults.json +6 -0
  62. package/src/editor/i18n/ja/fields.json +130 -0
  63. package/src/editor/i18n/ja/local.json +49 -0
  64. package/src/editor/i18n/ja/media.json +166 -0
  65. package/src/editor/i18n/ja/richtext.json +116 -0
  66. package/src/editor/i18n/ja/shell.json +181 -0
  67. package/src/editor/i18n/ja/system.json +14 -0
  68. package/src/editor/i18n/ja/validation.json +82 -0
  69. package/src/editor/i18n/locales.ts +13 -0
  70. package/src/editor/i18n/nl/connection.json +136 -0
  71. package/src/editor/i18n/nl/defaults.json +6 -0
  72. package/src/editor/i18n/nl/fields.json +130 -0
  73. package/src/editor/i18n/nl/local.json +49 -0
  74. package/src/editor/i18n/nl/media.json +166 -0
  75. package/src/editor/i18n/nl/richtext.json +116 -0
  76. package/src/editor/i18n/nl/shell.json +181 -0
  77. package/src/editor/i18n/nl/system.json +14 -0
  78. package/src/editor/i18n/nl/validation.json +82 -0
  79. package/src/editor/i18n/ru/connection.json +138 -0
  80. package/src/editor/i18n/ru/defaults.json +6 -0
  81. package/src/editor/i18n/ru/fields.json +132 -0
  82. package/src/editor/i18n/ru/local.json +49 -0
  83. package/src/editor/i18n/ru/media.json +166 -0
  84. package/src/editor/i18n/ru/richtext.json +116 -0
  85. package/src/editor/i18n/ru/shell.json +181 -0
  86. package/src/editor/i18n/ru/system.json +14 -0
  87. package/src/editor/i18n/ru/validation.json +82 -0
  88. package/src/editor/i18n/server.ts +13 -0
  89. package/src/editor/i18n/zh/connection.json +136 -0
  90. package/src/editor/i18n/zh/defaults.json +6 -0
  91. package/src/editor/i18n/zh/fields.json +130 -0
  92. package/src/editor/i18n/zh/local.json +49 -0
  93. package/src/editor/i18n/zh/media.json +166 -0
  94. package/src/editor/i18n/zh/richtext.json +116 -0
  95. package/src/editor/i18n/zh/shell.json +181 -0
  96. package/src/editor/i18n/zh/system.json +14 -0
  97. package/src/editor/i18n/zh/validation.json +82 -0
  98. package/src/editor/lib/ActionDialog.svelte +5 -4
  99. package/src/editor/lib/BlockEditor.svelte +305 -88
  100. package/src/editor/lib/CollectionPanel.svelte +17 -15
  101. package/src/editor/lib/DatePicker.svelte +15 -12
  102. package/src/editor/lib/ExtensionMount.svelte +12 -0
  103. package/src/editor/lib/Field.svelte +3 -2
  104. package/src/editor/lib/ImagePicker.svelte +82 -81
  105. package/src/editor/lib/MarkdownEditor.svelte +25 -24
  106. package/src/editor/lib/MediaPicker.svelte +6 -5
  107. package/src/editor/lib/MenuEditor.svelte +31 -30
  108. package/src/editor/lib/PreviewFrame.svelte +11 -6
  109. package/src/editor/lib/PropertiesTabs.svelte +3 -2
  110. package/src/editor/lib/PublishDialog.svelte +15 -14
  111. package/src/editor/lib/ReferenceSelect.svelte +3 -2
  112. package/src/editor/lib/SchemaField.svelte +15 -13
  113. package/src/editor/lib/SchemaPanel.svelte +20 -19
  114. package/src/editor/lib/Select.svelte +3 -2
  115. package/src/editor/lib/SeoGuidance.svelte +6 -5
  116. package/src/editor/lib/Settings.svelte +54 -54
  117. package/src/editor/lib/Shortcuts.svelte +7 -6
  118. package/src/editor/lib/SitePreview.svelte +10 -9
  119. package/src/editor/lib/TopBar.svelte +18 -17
  120. package/src/editor/lib/UnsavedChanges.svelte +7 -6
  121. package/src/editor/lib/VisualEditor.svelte +14 -13
  122. package/src/editor/lib/calendar.ts +10 -4
  123. package/src/editor/lib/embed-preview.ts +23 -5
  124. package/src/editor/lib/extension-preview.ts +121 -0
  125. package/src/editor/lib/github-auth.ts +23 -22
  126. package/src/editor/lib/github-backend.ts +96 -91
  127. package/src/editor/lib/images.ts +33 -31
  128. package/src/editor/lib/menu-tree.ts +5 -4
  129. package/src/editor/lib/preview-frame.ts +4 -3
  130. package/src/editor/lib/schema-edit.ts +3 -2
  131. package/src/editor/lib/settings.ts +5 -3
  132. package/src/editor/lib/slash-command.ts +1 -0
  133. package/src/editor/lib/tooltip.ts +1 -1
  134. package/src/editor/main.ts +9 -0
  135. package/src/editor/styles.css +6 -3
  136. package/src/env.d.ts +11 -1
  137. package/src/extensions.ts +160 -0
  138. package/src/manifest/collections.ts +6 -5
  139. package/src/manifest/menu.ts +12 -10
  140. package/src/manifest/navigation.ts +9 -8
  141. package/src/manifest/references.ts +8 -7
  142. package/src/manifest/seo.ts +4 -3
  143. package/src/manifest/types.ts +1 -0
  144. package/src/preview.ts +22 -2
  145. package/src/routes/local-api.ts +5 -3
  146. package/src/routes/local-documents.ts +20 -16
  147. package/src/routes/local-media.ts +21 -16
  148. package/src/routes/local-menu.ts +9 -7
  149. package/src/routes/local-post.ts +18 -12
  150. package/src/routes/media-files.ts +10 -8
  151. package/src/routes/rigel-page.astro +2 -1
  152. package/src/schema/store.ts +22 -20
  153. package/src/schema/types.ts +1 -0
  154. package/src/schema/validate.ts +73 -69
  155. package/src/slugs.ts +11 -10
  156. package/src/vite/manifest-plugin.ts +33 -1
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import { t } from "../i18n";
2
3
  import SchemaField from "./SchemaField.svelte";
3
4
  import Icon from "./Icon.svelte";
4
5
  import PropertiesTabs from "./PropertiesTabs.svelte";
@@ -92,15 +93,15 @@
92
93
 
93
94
  <div class="schema-panel" bind:this={host}>
94
95
  {#if docType.shared}
95
- <p class="shared-badge"><Icon name="info" size={12} /> {docType.demo ? "Field examples for testing. These values are separate from your website settings." : "Shared content. Changes appear on every page that references it."}</p>
96
+ <p class="shared-badge"><Icon name="info" size={12} /> {$t(docType.demo ? "fields.demoContentNotice" : "fields.sharedContentNotice")}</p>
96
97
  {/if}
97
98
  <div class:page-properties-header={!docType.shared}>
98
99
  {#if seoFields.length}<PropertiesTabs bind:tab />{/if}
99
100
  <section class="document-properties" hidden={tab !== "content"}>
100
- <h3>{docType.shared ? "Content" : "Page properties"}</h3>
101
+ <h3>{$t(docType.shared ? "fields.content" : "fields.pageProperties")}</h3>
101
102
  {#if !docType.shared}
102
- <label><span>URL slug</span><input type="text" value={String(values.slug ?? docId.slice(docType.name.length + 1))} oninput={(event) => onchange({ ...values, slug: event.currentTarget.value })} onblur={() => { if (Object.hasOwn(values, "slug")) onchange({ ...values, slug: slugify(String(values.slug)) }); }} /></label>
103
- <p class="hint">Changes the URL, not the document identifier or filename. The website route must use the slug metadata.</p>
103
+ <label><span>{$t("fields.urlSlug")}</span><input type="text" value={String(values.slug ?? docId.slice(docType.name.length + 1))} oninput={(event) => onchange({ ...values, slug: event.currentTarget.value })} onblur={() => { if (Object.hasOwn(values, "slug")) onchange({ ...values, slug: slugify(String(values.slug)) }); }} /></label>
104
+ <p class="hint">{$t("fields.documentSlugHint")}</p>
104
105
  {/if}
105
106
  {#each docFields.filter(field => docType.shared || !isSeoField(field.key)) as field (field.key)}
106
107
  <div data-schema-path={field.key}>
@@ -111,40 +112,40 @@
111
112
  </div>
112
113
  <div id="properties-content" role={seoFields.length ? "tabpanel" : undefined} aria-labelledby={seoFields.length ? "properties-tab-content" : undefined} hidden={tab !== "content"}>
113
114
  {#if creating}
114
- <p class="panel-note">New page. Save writes the JSON file; the route appears once the site has the file.</p>
115
+ <p class="panel-note">{$t("fields.newPageNotice")}</p>
115
116
  {/if}
116
117
  {#if sections.length || values.sections !== undefined}
117
118
  <section>
118
- <h3>Structure</h3>
119
+ <h3>{$t("fields.structure")}</h3>
119
120
  <ul class="structure-list">
120
121
  {#each sections as section, index (section._key)}
121
122
  <li class:active={selected?.sectionKey === section._key}>
122
123
  <button type="button" class="structure-row" onclick={() => onselect({ docId, path: "", sectionKey: String(section._key) })}>
123
- <span>{sectionLabel(schema, section._type)}</span>
124
+ <span>{sectionLabel(schema, section._type, $t("fields.section"))}</span>
124
125
  {#if devMode}<span class="schema-key">{section._key}</span>{/if}
125
126
  </button>
126
127
  <div class="structure-actions">
127
- <button type="button" aria-label={`Move ${sectionLabel(schema, section._type)} up`} data-tooltip="Move up" disabled={index === 0} onclick={() => setSections(moveItem(sections, index, -1) as Record<string, unknown>[])}>↑</button>
128
- <button type="button" aria-label={`Move ${sectionLabel(schema, section._type)} down`} data-tooltip="Move down" disabled={index === sections.length - 1} onclick={() => setSections(moveItem(sections, index, 1) as Record<string, unknown>[])}>↓</button>
129
- <button type="button" aria-label={`Duplicate ${sectionLabel(schema, section._type)}`} data-tooltip="Duplicate" onclick={() => setSections(duplicateItem(sections, index) as Record<string, unknown>[])}>⧉</button>
130
- <button type="button" aria-label={`Remove ${sectionLabel(schema, section._type)}`} data-tooltip="Remove" onclick={() => removeSection(index)}>×</button>
128
+ <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>
129
+ <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>
130
+ <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>
131
+ <button type="button" aria-label={$t("fields.removeSection", { label: sectionLabel(schema, section._type) })} data-tooltip={$t("fields.remove")} onclick={() => removeSection(index)}>×</button>
131
132
  </div>
132
133
  </li>
133
134
  {/each}
134
135
  </ul>
135
136
  <div class="add-section">
136
- <Select ariaLabel="Section type to add" value={addType} onchange={next => { addType = String(next); }} options={[{ value: "", label: "Choose a section…" }, ...allowedTypes.map(type => ({ value: type.name, label: type.title }))]} />
137
- <button type="button" class="outlined" disabled={!addType} onclick={addSection}>Add section</button>
137
+ <Select ariaLabel={$t("fields.sectionTypeToAdd")} value={addType} onchange={next => { addType = String(next); }} options={[{ value: "", label: $t("fields.chooseSection") }, ...allowedTypes.map(type => ({ value: type.name, label: type.title }))]} />
138
+ <button type="button" class="outlined" disabled={!addType} onclick={addSection}>{$t("fields.addSection")}</button>
138
139
  </div>
139
- {#if pending.length}<p class="panel-note" role="status">{pending.length} new section{pending.length === 1 ? "" : "s"} will appear after Save.</p>{/if}
140
+ {#if pending.length}<p class="panel-note" role="status">{$t("fields.pendingSections", { count: pending.length })}</p>{/if}
140
141
  </section>
141
142
  {/if}
142
143
 
143
144
  {#if selectedSection || (selectedField && selected?.path.includes("."))}
144
145
  <section class="section-properties">
145
- <h3>Section properties</h3>
146
+ <h3>{$t("fields.sectionProperties")}</h3>
146
147
  {#if selectedSection && selected?.sectionKey}
147
- <p class="selected-path">{selectedField ? `${selectedField.label} · ` : ""}{selectedSection.title}</p>
148
+ <p class="selected-path">{selectedField ? `${selectedField.labelKey ? $t(selectedField.labelKey) : selectedField.label} · ` : ""}{selectedSection.title}</p>
148
149
  {#each selectedSection.fields as field (field.key)}
149
150
  {@const reference = field.type === "reference" ? sections.find((section) => section._key === selected?.sectionKey)?.[field.key] as { _ref?: string } | undefined : undefined}
150
151
  <div data-schema-path={`sections.${selected.sectionKey}.${field.key}`}>
@@ -158,12 +159,12 @@
158
159
  onvalue={(next) => onchange(replaceAt(values, `sections.${selected!.sectionKey}.${field.key}`, next))}
159
160
  />
160
161
  {#if reference?._ref && documents.some((doc) => doc.id === reference._ref)}
161
- <button type="button" class="outlined" onclick={() => oneditdocument(reference._ref!)}>Edit {field.label.toLowerCase()} ↗</button>
162
+ <button type="button" class="outlined" onclick={() => oneditdocument(reference._ref!)}>{$t("fields.editReference", { label: field.label.toLowerCase() })}</button>
162
163
  {/if}
163
164
  </div>
164
165
  {/each}
165
166
  {:else if selectedField && selected}
166
- <p class="selected-path">{selectedField.label}{selected.sectionKey ? ` · ${sectionLabel(schema, sections.find((section) => section._key === selected.sectionKey)?._type)}` : ""}</p>
167
+ <p class="selected-path">{selectedField.labelKey ? $t(selectedField.labelKey) : selectedField.label}{selected.sectionKey ? ` · ${sectionLabel(schema, sections.find((section) => section._key === selected.sectionKey)?._type, $t("fields.section"))}` : ""}</p>
167
168
  <SchemaField
168
169
  {schema}
169
170
  {documents}
@@ -181,7 +182,7 @@
181
182
  {#if seoFields.length}
182
183
  <div id="properties-seo" role="tabpanel" aria-labelledby="properties-tab-seo" hidden={tab !== "seo"}>
183
184
  <section class="seo-fields">
184
- <h3>SEO</h3>
185
+ <h3>{$t("fields.seo")}</h3>
185
186
  {#each seoFields as field (field.key)}
186
187
  <div data-schema-path={field.key}>
187
188
  <SchemaField {schema} {documents} {devMode} {field} base={field.key} value={values[field.key]} onvalue={(next) => onchange(replaceAt(values, field.key, next))} />
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import { t } from "../i18n";
2
3
  import { tick } from "svelte";
3
4
  import Icon from "./Icon.svelte";
4
5
  import type { ICONS } from "./icons";
@@ -125,7 +126,7 @@
125
126
  <div class="custom-select" onfocusout={() => { if (open) requestAnimationFrame(() => { if (trigger.ownerDocument.activeElement?.tagName === "IFRAME") close(); }); }}>
126
127
  {#if label}<span class="select-label" id={`${id}-label`}>{label}</span>{/if}
127
128
  <button bind:this={trigger} type="button" class="select-trigger" class:icon-only={!!icon} role="combobox" aria-label={label ? undefined : ariaLabel} aria-labelledby={label ? `${id}-label` : undefined} aria-haspopup="listbox" aria-expanded={open} aria-controls={`${id}-list`} aria-activedescendant={open && active >= 0 ? `${id}-option-${active}` : undefined} popovertarget={`${id}-list`} data-tooltip={tooltip} data-tooltip-position={tooltipPosition} data-tooltip-align={tooltipAlign} {disabled} onclick={event => { event.preventDefault(); menu.matches(":popover-open") ? close() : void reveal(); }} onkeydown={onkey}>
128
- {#if icon}<Icon name={icon} size={18} />{:else}<span>{text || (multiple ? `Select ${label.toLowerCase() || "options"}…` : "Choose…")}</span><Icon name="caretDown" size={12} />{/if}
129
+ {#if icon}<Icon name={icon} size={18} />{:else}<span>{text || (multiple ? label ? $t("fields.selectLabel", { label: label.toLowerCase() }) : $t("fields.selectOptions") : $t("fields.choose"))}</span><Icon name="caretDown" size={12} />{/if}
129
130
  </button>
130
131
  <select hidden aria-hidden="true" tabindex="-1" aria-label={ariaLabel} {multiple} {disabled} {value} onchange={event => { value = multiple ? Array.from(event.currentTarget.selectedOptions, option => option.value) : event.currentTarget.value; onchange(value); }}>
131
132
  {#each options as option (option.value)}<option value={option.value} disabled={option.disabled}>{option.label}</option>{/each}
@@ -137,6 +138,6 @@
137
138
  <span>{option.label}</span><span class="select-check" aria-hidden="true">{selected.includes(option.value) ? "✓" : ""}</span>
138
139
  </div>
139
140
  {/each}
140
- {#if !options.length}<p class="select-empty">No options available.</p>{/if}
141
+ {#if !options.length}<p class="select-empty">{$t("fields.noOptions")}</p>{/if}
141
142
  </div>
142
143
  </div>
@@ -1,21 +1,22 @@
1
1
  <script lang="ts">
2
+ import { t } from "../i18n";
2
3
  let { field, value }: { field: string; value: unknown } = $props();
3
- const limits = $derived(field === "metaTitle" ? { min: 50, max: 60, label: "SEO title" } : { min: 120, max: 160, label: "SEO description" });
4
+ const limits = $derived(field === "metaTitle" ? { min: 50, max: 60 } : { min: 120, max: 160 });
4
5
  const count = $derived(typeof value === "string" ? [...value].length : 0);
5
- const status = $derived(!count ? "Not set" : count < limits.min ? "Too short" : count > limits.max ? "Too long" : "Recommended length");
6
+ const status = $derived($t(!count ? "fields.seoNotSet" : count < limits.min ? "fields.seoTooShort" : count > limits.max ? "fields.seoTooLong" : "fields.seoRecommendedLength"));
6
7
  const tone = $derived(!count ? "empty" : count > limits.max || count < limits.min / 2 ? "danger" : count < limits.min ? "warning" : "success");
7
8
  </script>
8
9
 
9
10
  {#if field === "metaTitle" || field === "description"}
10
11
  <div class="seo-length" data-seo-length={field} data-tone={tone}>
11
- <div class="seo-length-track" role="meter" aria-label={`${limits.label} length`} aria-valuemin={0} aria-valuemax={Math.max(limits.max, count)} aria-valuenow={count} aria-valuetext={`${count} characters. ${status}. Recommended ${limits.min} to ${limits.max}.`} data-tooltip={status}>
12
+ <div class="seo-length-track" role="meter" aria-label={$t(field === "metaTitle" ? "fields.seoTitleLength" : "fields.seoDescriptionLength")} aria-valuemin={0} aria-valuemax={Math.max(limits.max, count)} aria-valuenow={count} aria-valuetext={$t("fields.seoLengthDetails", { count, status, min: limits.min, max: limits.max })} data-tooltip={status}>
12
13
  <span class="seo-length-fill" style:width={`${Math.min(count / limits.max * 100, 100)}%`}></span>
13
14
  </div>
14
15
  <div class="seo-length-summary hint">
15
- <span>Recommended: {limits.min}–{limits.max} characters.</span>
16
+ <span>{$t("fields.seoRecommendedCharacters", { min: limits.min, max: limits.max })}</span>
16
17
  <span class="seo-length-count" aria-hidden="true">{count}/{limits.max}</span>
17
18
  </div>
18
19
  </div>
19
20
  {:else if field === "ogImage"}
20
- <p class="hint">Recommended size: 1200 × 628 px.</p>
21
+ <p class="hint">{$t("fields.seoRecommendedImageSize")}</p>
21
22
  {/if}
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import { t, availableLocales, localeNames } from "../i18n";
2
3
  import { getKeys, setKeys, STOCK_PROVIDERS } from "./images";
3
4
  import type { RigelSettings } from "./settings";
4
5
  import type { Manifest } from "../../manifest/types";
@@ -24,14 +25,14 @@
24
25
  let keysError = $state("");
25
26
  let eraseError = $state("");
26
27
 
27
- const panes: { id: PaneId; label: string }[] = [
28
- { id: "appearance", label: "Appearance" },
29
- { id: "editor", label: "Editor" },
30
- { id: "connection", label: "Connection" },
31
- { id: "media", label: "Media" },
32
- { id: "language", label: "Language" },
33
- { id: "advanced", label: "Advanced" },
34
- ];
28
+ const panes: { id: PaneId; label: string }[] = $derived([
29
+ { id: "appearance", label: $t("shell.appearance") },
30
+ { id: "editor", label: $t("shell.editor") },
31
+ { id: "connection", label: $t("shell.connection") },
32
+ { id: "media", label: $t("shell.media") },
33
+ { id: "language", label: $t("shell.language") },
34
+ { id: "advanced", label: $t("shell.advanced") },
35
+ ]);
35
36
 
36
37
  const PANE_ICONS = {
37
38
  appearance: "palette",
@@ -47,7 +48,7 @@
47
48
  setKeys(Object.fromEntries(providerIds.map((id) => [id, keys[id]?.trim() || undefined])));
48
49
  keysError = "";
49
50
  } catch {
50
- keysError = "API keys could not be saved in this browser. Allow browser storage and try again.";
51
+ keysError = $t("shell.apiKeysStorageError");
51
52
  }
52
53
  });
53
54
  </script>
@@ -58,10 +59,10 @@
58
59
  onclick={onclose}
59
60
  onkeydown={(e) => e.key === "Escape" && onclose()}
60
61
  >
61
- <div class="rigel-modal settings-modal" role="dialog" aria-label="Settings" onclick={(e) => e.stopPropagation()}>
62
+ <div class="rigel-modal settings-modal" role="dialog" aria-label={$t("shell.settings")} onclick={(e) => e.stopPropagation()}>
62
63
  <div class="settings-head">
63
- <h2>Settings</h2>
64
- <button class="close" onclick={onclose} aria-label="Close">
64
+ <h2>{$t("shell.settings")}</h2>
65
+ <button class="close" onclick={onclose} aria-label={$t("shell.close")}>
65
66
  <Icon name="x" size={14} weight="bold" />
66
67
  </button>
67
68
  </div>
@@ -80,67 +81,67 @@
80
81
 
81
82
  <div class="settings-pane">
82
83
  {#if active === "appearance"}
83
- <h3>Appearance</h3>
84
+ <h3>{$t("shell.appearance")}</h3>
84
85
  <fieldset>
85
- <legend>Theme</legend>
86
+ <legend>{$t("shell.theme")}</legend>
86
87
  <div class="theme-options">
87
- {#each ["system", "light", "dark"] as const as t}
88
+ {#each ["system", "light", "dark"] as const as theme}
88
89
  <label class="theme-option">
89
- <input type="radio" bind:group={s.theme} value={t} />
90
- <span>{t[0].toUpperCase() + t.slice(1)}</span>
90
+ <input type="radio" bind:group={s.theme} value={theme} />
91
+ <span>{$t({ system: "shell.themeSystem", light: "shell.themeLight", dark: "shell.themeDark" }[theme])}</span>
91
92
  </label>
92
93
  {/each}
93
94
  </div>
94
95
  </fieldset>
95
96
  <fieldset class="accessibility-options">
96
- <legend>Accessibility</legend>
97
+ <legend>{$t("shell.accessibility")}</legend>
97
98
  <label class="inline">
98
99
  <input type="checkbox" role="switch" class="switch" bind:checked={s.highContrast} aria-describedby="high-contrast-hint" />
99
- <span>High contrast mode</span>
100
+ <span>{$t("shell.highContrastMode")}</span>
100
101
  </label>
101
- <p class="hint" id="high-contrast-hint">Stronger text and borders in both light and dark themes.</p>
102
+ <p class="hint" id="high-contrast-hint">{$t("shell.highContrastHint")}</p>
102
103
  </fieldset>
103
104
 
104
105
  {:else if active === "editor"}
105
- <h3>Editor experience</h3>
106
+ <h3>{$t("shell.editorExperience")}</h3>
106
107
  <fieldset>
107
- <legend>Article body editing</legend>
108
- <label class="radio"><input type="radio" bind:group={s.userPref.editorExperience} value="sidebar" /><span>Sidebar</span></label>
109
- <label class="radio"><input type="radio" bind:group={s.userPref.editorExperience} value="inline" /><span>Inline preview</span></label>
108
+ <legend>{$t("shell.articleBodyEditing")}</legend>
109
+ <label class="radio"><input type="radio" bind:group={s.userPref.editorExperience} value="sidebar" /><span>{$t("shell.sidebar")}</span></label>
110
+ <label class="radio"><input type="radio" bind:group={s.userPref.editorExperience} value="inline" /><span>{$t("shell.inlinePreview")}</span></label>
110
111
  </fieldset>
111
- <p class="hint">Inline preview lets you click an article body and write directly on the page. Other fields stay in the sidebar. This preference saves in this browser; content still requires Save.</p>
112
+ <p class="hint">{$t("shell.editorExperienceHint")}</p>
112
113
  {:else if active === "connection"}
113
- <h3>Connection</h3>
114
+ <h3>{$t("shell.connection")}</h3>
114
115
  {#if github}
115
116
  <dl class="connection-details">
116
- <dt>GitHub account</dt>
117
- <dd>{#if user}{user.name ?? user.login}<small>@{user.login}</small>{:else}No GitHub account connected.{/if}</dd>
118
- <dt>Repository</dt>
117
+ <dt>{$t("shell.githubAccount")}</dt>
118
+ <dd>{#if user}{user.name ?? user.login}<small>@{user.login}</small>{:else}{$t("shell.noGithubAccount")}{/if}</dd>
119
+ <dt>{$t("shell.repository")}</dt>
119
120
  <dd>{github.repo}</dd>
120
- <dt>Editing branch</dt>
121
+ <dt>{$t("shell.editingBranch")}</dt>
121
122
  <dd>{github.branch}</dd>
122
123
  {#if github.publishBranch}
123
- <dt>Publishing branch</dt>
124
+ <dt>{$t("shell.publishingBranch")}</dt>
124
125
  <dd>{github.publishBranch}</dd>
125
126
  {/if}
126
127
  </dl>
127
128
  {:else}
128
- <p>Local mode</p>
129
- <p class="hint">No GitHub account connected. Content saves to local files when you press Save.</p>
129
+ <p>{$t("shell.localMode")}</p>
130
+ <p class="hint">{$t("shell.localModeHint")}</p>
130
131
  {/if}
131
132
  {:else if active === "media"}
132
133
 
133
134
  <section class="media-settings-section">
134
- <h3>Stock Photo Service API Keys</h3>
135
- <p class="hint">Manage API keys for stock photo services. Keys save automatically in this browser only.</p>
135
+ <h3>{$t("shell.stockPhotoApiKeys")}</h3>
136
+ <p class="hint">{$t("shell.stockPhotoApiKeysHint")}</p>
136
137
  {#each providerIds as id (id)}
137
138
  {@const provider = STOCK_PROVIDERS[id]}
138
- {@const keyLabel = `${provider.label} ${id === "unsplash" ? "Access Key" : "API key"}`}
139
+ {@const keyLabel = $t(id === "unsplash" ? "shell.providerAccessKey" : "shell.providerApiKey", { name: provider.label })}
139
140
  {@const invalid = !!keys[id]?.trim() && !new RegExp(`^${provider.pattern}$`).test(keys[id]!.trim())}
140
141
  <div class="stock-key">
141
142
  <div class="stock-key-head">
142
143
  <h4><label for={`rigel-key-${id}`}>{keyLabel}</label></h4>
143
- <a href={provider.keyURL} target="_blank" rel="noopener noreferrer">Get {id === "unsplash" ? "Access Key" : "API key"}</a>
144
+ <a href={provider.keyURL} target="_blank" rel="noopener noreferrer">{$t(id === "unsplash" ? "shell.getAccessKey" : "shell.getApiKey")}</a>
144
145
  </div>
145
146
  <div class="secret-input">
146
147
  <input
@@ -154,47 +155,46 @@
154
155
  aria-invalid={invalid}
155
156
  aria-describedby={invalid ? `rigel-key-${id}-error` : undefined}
156
157
  />
157
- <button type="button" aria-label={`${revealed[id] ? "Hide" : "Show"} ${keyLabel}`} aria-controls={`rigel-key-${id}`} aria-pressed={revealed[id] ?? false} onclick={() => revealed[id] = !revealed[id]}>
158
+ <button type="button" aria-label={$t(revealed[id] ? "shell.hideKey" : "shell.showKey", { name: keyLabel })} aria-controls={`rigel-key-${id}`} aria-pressed={revealed[id] ?? false} onclick={() => revealed[id] = !revealed[id]}>
158
159
  <Icon name={revealed[id] ? "eyeSlash" : "eye"} size={18} />
159
160
  </button>
160
161
  </div>
161
- {#if invalid}<p class="error" id={`rigel-key-${id}-error`}>Check the {keyLabel} format.</p>{/if}
162
+ {#if invalid}<p class="error" id={`rigel-key-${id}-error`}>{$t("shell.keyFormatError", { name: keyLabel })}</p>{/if}
162
163
  </div>
163
164
  {/each}
164
165
 
165
166
  </section>
166
167
 
167
168
  {:else if active === "language"}
168
- <h3>Language</h3>
169
+ <h3>{$t("shell.language")}</h3>
169
170
  <fieldset>
170
- <legend>Editor interface language</legend>
171
- {#each ["en", "es", "ru"] as const as lang}
171
+ <legend>{$t("shell.editorInterfaceLanguage")}</legend>
172
+ {#each availableLocales as lang}
172
173
  <label class="radio">
173
174
  <input type="radio" bind:group={s.language} value={lang} />
174
- <span>{{ en: "English", es: "Español", ru: "Русский" }[lang]}{lang !== "en" ? " (soon)" : ""}</span>
175
+ <span>{localeNames[lang]}</span>
175
176
  </label>
176
177
  {/each}
177
178
  </fieldset>
178
- <p class="hint">English is the only shipped locale today.</p>
179
179
 
180
180
  {:else}
181
- <h3>Developer Mode</h3>
181
+ <h3>{$t("shell.developerMode")}</h3>
182
182
  <label class="inline">
183
183
  <input type="checkbox" role="switch" class="switch" bind:checked={s.devMode} />
184
- <span>Enable dev mode</span>
184
+ <span>{$t("shell.enableDevMode")}</span>
185
185
  </label>
186
- <p class="hint">Shows raw JSON of the current draft + field keys next to labels.</p>
186
+ <p class="hint">{$t("shell.devModeHint")}</p>
187
187
  <div class="clear-data">
188
- <h3>Clear Data</h3>
189
- <p class="hint">Rigel does not currently store file contents or thumbnails in a persistent browser cache.</p>
190
- <button type="button" class="danger-btn" disabled>Clear File Cache</button>
191
- <p class="hint">Erase Rigel settings, API keys and unsaved drafts from this browser. Files already saved to your repository are not affected.</p>
188
+ <h3>{$t("shell.clearData")}</h3>
189
+ <p class="hint">{$t("shell.fileCacheHint")}</p>
190
+ <button type="button" class="danger-btn" disabled>{$t("shell.clearFileCache")}</button>
191
+ <p class="hint">{$t("shell.eraseAllDataHint")}</p>
192
192
  <button type="button" class="danger-btn" disabled={saving} onclick={() => {
193
193
  eraseError = "";
194
194
  try { onerase(); }
195
- catch { eraseError = "Could not erase browser data. Check browser storage permissions and try again."; }
196
- }}>Erase All Data</button>
197
- {#if saving}<p class="hint" role="status">Wait for the current save to finish.</p>{/if}
195
+ catch { eraseError = $t("shell.eraseDataError"); }
196
+ }}>{$t("shell.eraseAllData")}</button>
197
+ {#if saving}<p class="hint" role="status">{$t("shell.waitForSave")}</p>{/if}
198
198
  {#if eraseError}<p class="error" role="alert">{eraseError}</p>{/if}
199
199
  </div>
200
200
  {/if}
@@ -1,10 +1,11 @@
1
1
  <script lang="ts">
2
+ import { t } from "../i18n";
2
3
  let { onclose }: { onclose: () => void } = $props();
3
4
 
4
- const rows: [string, string][] = [
5
- ["Search", "⌘/Ctrl + K"],
6
- ["Close dialog", "Esc"],
7
- ];
5
+ const rows: [string, string][] = $derived([
6
+ [$t("shell.search"), $t("shell.searchShortcut")],
7
+ [$t("shell.closeDialog"), $t("shell.escapeKey")],
8
+ ]);
8
9
  </script>
9
10
 
10
11
  <div
@@ -13,8 +14,8 @@
13
14
  onclick={onclose}
14
15
  onkeydown={(e) => e.key === "Escape" && onclose()}
15
16
  >
16
- <div class="rigel-modal" role="dialog" aria-label="Keyboard shortcuts" onclick={(e) => e.stopPropagation()}>
17
- <h2>Keyboard shortcuts</h2>
17
+ <div class="rigel-modal" role="dialog" aria-label={$t("shell.keyboardShortcuts")} onclick={(e) => e.stopPropagation()}>
18
+ <h2>{$t("shell.keyboardShortcuts")}</h2>
18
19
  <table class="keys">
19
20
  <tbody>
20
21
  {#each rows as [action, key]}
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import { t } from "../i18n";
2
3
  import { untrack } from "svelte";
3
4
  import Icon from "./Icon.svelte";
4
5
  import { patchPreview, previewUrl, type PreviewConfig, type PreviewTarget } from "../../preview";
@@ -25,27 +26,27 @@
25
26
  try {
26
27
  const doc = frame?.contentDocument;
27
28
  doc?.addEventListener("contextmenu", event => { if (!devMode) event.preventDefault(); }, true);
28
- if (!doc) throw new Error("The prepared page could not be loaded.");
29
- if (!doc.querySelector('[data-rigel-text], [data-rigel-richtext], [data-rigel-image]')) throw new Error("This page has no prepared Rigel anchors.");
29
+ if (!doc) throw new Error($t("richtext.preparedPageUnavailable"));
30
+ if (!doc.querySelector('[data-rigel-text], [data-rigel-richtext], [data-rigel-image]')) throw new Error($t("richtext.noPreparedAnchors"));
30
31
  doc.addEventListener("click", event => event.preventDefault(), true);
31
32
  doc.addEventListener("submit", event => event.preventDefault(), true);
32
33
  loaded = true;
33
34
  patch();
34
- } catch (cause) { error = cause instanceof Error ? cause.message : "Preview unavailable"; }
35
+ } catch (cause) { error = cause instanceof Error ? cause.message : $t("richtext.previewUnavailable"); }
35
36
  }
36
37
  $effect(() => { JSON.stringify(values); void body; patch(); });
37
38
  </script>
38
39
 
39
- <div class="site-preview" aria-label="Website preview">
40
+ <div class="site-preview" aria-label={$t("richtext.websitePreview")}>
40
41
  <div class="preview-heading">
41
- <strong>Website preview</strong>
42
- {#if websitePath}<a class="icon-btn visual-live" href={websitePath} target="_blank" rel="noopener noreferrer" aria-label="Preview on website" data-tooltip="Preview on website"><Icon name="external" size={18} /></a>{/if}
42
+ <strong>{$t("richtext.websitePreview")}</strong>
43
+ {#if websitePath}<a class="icon-btn visual-live" href={websitePath} target="_blank" rel="noopener noreferrer" aria-label={$t("richtext.previewOnWebsite")} data-tooltip={$t("richtext.previewOnWebsite")}><Icon name="external" size={18} /></a>{/if}
43
44
  </div>
44
45
  {#if error}<p role="status" class="preview-error">{error}</p>{/if}
45
46
  {#if path}
46
- {#key key}<PreviewFrame bind:frame url={path} title="Live website draft preview" {onload} onloading={() => { loaded = false; error = ""; }} onerror={(message) => { loaded = false; error = message; }} />{/key}
47
- <small>Prepared template · Scripts, navigation and forms disabled. Unanchored fields and lists are not previewed.</small>
47
+ {#key key}<PreviewFrame bind:frame url={path} title={$t("richtext.liveDraftPreview")} {onload} onloading={() => { loaded = false; error = ""; }} onerror={(message) => { loaded = false; error = message; }} />{/key}
48
+ <small>{$t("richtext.preparedTemplateHint")}</small>
48
49
  {:else}
49
- <p role="status">{target.kind === "menu" ? "Menu preview is not supported." : "No prepared preview URL is mapped to this content."}</p>
50
+ <p role="status">{target.kind === "menu" ? $t("richtext.menuPreviewUnsupported") : $t("richtext.noContentPreview")}</p>
50
51
  {/if}
51
52
  </div>
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import { t } from "../i18n";
2
3
  import Icon from "./Icon.svelte";
3
4
  import SidebarIcon from "./SidebarIcon.svelte";
4
5
  import { tick } from "svelte";
@@ -46,34 +47,34 @@
46
47
  for (const p of manifest.pages) {
47
48
  all.push({
48
49
  label: p.label,
49
- hint: "Page",
50
+ hint: $t("shell.page"),
50
51
  action: () => onselect({ kind: "page", slug: p.slug }),
51
52
  });
52
53
  }
53
54
  for (const doc of manifest.documents ?? []) {
54
55
  all.push({
55
56
  label: String(doc.data.title ?? doc.data.name ?? doc.slug),
56
- hint: `${doc.type} · ${doc.slug}`,
57
+ hint: $t("shell.searchItemHint", { name: doc.type, slug: doc.slug }),
57
58
  action: () => onselect({ kind: "document", type: doc.type, id: doc.id }),
58
59
  });
59
60
  }
60
61
  for (const c of manifest.collections ?? []) {
61
62
  all.push({
62
63
  label: c.label,
63
- hint: "Collection",
64
+ hint: $t("shell.collection"),
64
65
  action: () => onselect({ kind: "collection", name: c.name }),
65
66
  });
66
67
  for (const po of c.posts) {
67
68
  all.push({
68
69
  label: String(po.frontmatter.title ?? po.slug),
69
- hint: `${c.label} · ${po.slug}`,
70
+ hint: $t("shell.searchItemHint", { name: c.label, slug: po.slug }),
70
71
  action: () => onselect({ kind: "post", collection: c.name, slug: po.slug }),
71
72
  });
72
73
  }
73
74
  }
74
75
  if (manifest.menus) {
75
- all.push({ label: "Menus", hint: "Collection", action: () => onselect({ kind: "menus" }) });
76
- for (const m of manifest.menus.entries) all.push({ label: m.title, hint: `Menu · ${m.slug}`, action: () => onselect({ kind: "menu", slug: m.slug }) });
76
+ all.push({ label: $t("shell.menus"), hint: $t("shell.collection"), action: () => onselect({ kind: "menus" }) });
77
+ for (const m of manifest.menus.entries) all.push({ label: m.title, hint: $t("shell.searchMenuHint", { slug: m.slug }), action: () => onselect({ kind: "menu", slug: m.slug }) });
77
78
  }
78
79
  if (!q) return all.slice(0, 12);
79
80
  return all.filter((h) => h.label.toLowerCase().includes(q) || h.hint.toLowerCase().includes(q)).slice(0, 12);
@@ -112,9 +113,9 @@
112
113
 
113
114
  <svelte:window onkeydown={onKeydown} />
114
115
 
115
- <button class="search-trigger" aria-label="Search content" data-tooltip="Search content (⌘K)" onclick={() => { active = -1; open = true; }}>
116
+ <button class="search-trigger" aria-label={$t("shell.searchContent")} data-tooltip={$t("shell.searchContentTooltip")} onclick={() => { active = -1; open = true; }}>
116
117
  <SidebarIcon name="magnifying-glass" />
117
- <span class="sidebar-label">Search…</span>
118
+ <span class="sidebar-label">{$t("shell.searchPlaceholder")}</span>
118
119
  <kbd>⌘K</kbd>
119
120
  </button>
120
121
 
@@ -125,7 +126,7 @@
125
126
  onclick={() => (open = false)}
126
127
  onkeydown={(e) => e.key === "Escape" && (open = false)}
127
128
  >
128
- <div class="palette" role="dialog" aria-label="Search content" onclick={(e) => e.stopPropagation()}>
129
+ <div class="palette" role="dialog" aria-label={$t("shell.searchContent")} onclick={(e) => e.stopPropagation()}>
129
130
  <div class="palette-search">
130
131
  <Icon name="magnifier" size={20} />
131
132
  <input
@@ -135,15 +136,15 @@
135
136
  aria-controls="rigel-search-results"
136
137
  aria-autocomplete="list"
137
138
  aria-activedescendant={active >= 0 && hits[active] ? `rigel-search-hit-${active}` : undefined}
138
- aria-label="Search pages, collections, posts"
139
- placeholder="Search pages, collections, posts…"
139
+ aria-label={$t("shell.searchPagesCollectionsPosts")}
140
+ placeholder={$t("shell.searchPagesCollectionsPostsPlaceholder")}
140
141
  bind:value={query}
141
142
  oninput={() => active = -1}
142
143
  bind:this={inputEl}
143
144
  />
144
- <kbd>esc</kbd>
145
+ <kbd>{$t("shell.escapeKeyLowercase")}</kbd>
145
146
  </div>
146
- <ul id="rigel-search-results" role="listbox" aria-label="Search results">
147
+ <ul id="rigel-search-results" role="listbox" aria-label={$t("shell.searchResults")}>
147
148
  {#each hits as h, i (h.hint + h.label)}
148
149
  <li role="presentation">
149
150
  <button id={`rigel-search-hit-${i}`} role="option" aria-selected={active === i} onfocus={() => active = i} onclick={() => trigger(h)}>
@@ -152,13 +153,13 @@
152
153
  </button>
153
154
  </li>
154
155
  {:else}
155
- <li class="empty" role="presentation">No results</li>
156
+ <li class="empty" role="presentation">{$t("shell.noResults")}</li>
156
157
  {/each}
157
158
  </ul>
158
159
  <div class="palette-footer">
159
- <span><kbd>↑</kbd><kbd>↓</kbd> navigate</span>
160
- <span><kbd>↵</kbd> open</span>
161
- <span><kbd>esc</kbd> close</span>
160
+ <span><kbd>↑</kbd><kbd>↓</kbd> {$t("shell.searchNavigate")}</span>
161
+ <span><kbd>↵</kbd> {$t("shell.searchOpen")}</span>
162
+ <span><kbd>{$t("shell.escapeKeyLowercase")}</kbd> {$t("shell.searchClose")}</span>
162
163
  </div>
163
164
  </div>
164
165
  </div>
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import { t } from "../i18n";
2
3
  import Icon from "./Icon.svelte";
3
4
  let { busy, error, onsave, ondiscard, oncancel }: { busy: boolean; error: string; onsave: () => void; ondiscard: () => void; oncancel: () => void } = $props();
4
5
  let dialog: HTMLDialogElement;
@@ -13,15 +14,15 @@
13
14
  <dialog bind:this={dialog} class="rigel rigel-modal-backdrop unsaved-dialog" aria-labelledby="unsaved-title" aria-describedby="unsaved-description" oncancel={(event) => { event.preventDefault(); if (!busy) oncancel(); }}>
14
15
  <div class="rigel-modal">
15
16
  <div class="unsaved-head">
16
- <h2 id="unsaved-title">Unsaved changes</h2>
17
- <button type="button" class="icon-btn" aria-label="Keep editing" disabled={busy} onclick={oncancel}><Icon name="x" size={16} /></button>
17
+ <h2 id="unsaved-title">{$t("connection.unsavedTitle")}</h2>
18
+ <button type="button" class="icon-btn" aria-label={$t("connection.keepEditing")} disabled={busy} onclick={oncancel}><Icon name="x" size={16} /></button>
18
19
  </div>
19
- <p id="unsaved-description">You have unsaved changes. Save before leaving, or discard this draft?</p>
20
+ <p id="unsaved-description">{$t("connection.unsavedDescription")}</p>
20
21
  {#if error}<p role="alert">{error}</p>{/if}
21
22
  <div class="unsaved-actions">
22
- <button type="button" bind:this={cancel} disabled={busy} onclick={oncancel}>Keep editing</button>
23
- <button type="button" disabled={busy} onclick={ondiscard}>Discard changes</button>
24
- <button type="button" class="primary" disabled={busy} onclick={onsave}>{busy ? 'Saving…' : 'Save and leave'}</button>
23
+ <button type="button" bind:this={cancel} disabled={busy} onclick={oncancel}>{$t("connection.keepEditing")}</button>
24
+ <button type="button" disabled={busy} onclick={ondiscard}>{$t("connection.discardChanges")}</button>
25
+ <button type="button" class="primary" disabled={busy} onclick={onsave}>{busy ? $t("connection.saving") : $t("connection.saveAndLeave")}</button>
25
26
  </div>
26
27
  </div>
27
28
  </dialog>