@jant/core 0.3.25 → 0.3.27

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 (133) hide show
  1. package/dist/app.js +70 -563
  2. package/dist/auth.js +3 -0
  3. package/dist/client.js +1 -0
  4. package/dist/i18n/locales/en.js +1 -1
  5. package/dist/i18n/locales/zh-Hans.js +1 -1
  6. package/dist/i18n/locales/zh-Hant.js +1 -1
  7. package/dist/lib/avatar-upload.js +134 -0
  8. package/dist/lib/config.js +39 -0
  9. package/dist/lib/constants.js +10 -10
  10. package/dist/lib/favicon.js +102 -0
  11. package/dist/lib/image.js +13 -17
  12. package/dist/lib/media-helpers.js +2 -2
  13. package/dist/lib/navigation.js +23 -3
  14. package/dist/lib/render.js +10 -1
  15. package/dist/lib/schemas.js +31 -0
  16. package/dist/lib/timezones.js +388 -0
  17. package/dist/lib/view.js +1 -1
  18. package/dist/routes/api/posts.js +1 -1
  19. package/dist/routes/api/upload.js +3 -3
  20. package/dist/routes/auth/reset.js +221 -0
  21. package/dist/routes/auth/setup.js +194 -0
  22. package/dist/routes/auth/signin.js +176 -0
  23. package/dist/routes/dash/collections.js +23 -415
  24. package/dist/routes/dash/media.js +12 -392
  25. package/dist/routes/dash/pages.js +7 -330
  26. package/dist/routes/dash/redirects.js +18 -12
  27. package/dist/routes/dash/settings.js +198 -577
  28. package/dist/routes/feed/rss.js +2 -1
  29. package/dist/routes/feed/sitemap.js +4 -2
  30. package/dist/routes/pages/featured.js +5 -1
  31. package/dist/routes/pages/home.js +26 -1
  32. package/dist/routes/pages/latest.js +45 -0
  33. package/dist/services/post.js +30 -50
  34. package/dist/types/bindings.js +3 -0
  35. package/dist/types/config.js +147 -0
  36. package/dist/types/constants.js +27 -0
  37. package/dist/types/entities.js +3 -0
  38. package/dist/types/operations.js +3 -0
  39. package/dist/types/props.js +3 -0
  40. package/dist/types/views.js +5 -0
  41. package/dist/types.js +8 -111
  42. package/dist/ui/color-themes.js +33 -33
  43. package/dist/ui/compose/ComposeDialog.js +36 -21
  44. package/dist/ui/dash/PageForm.js +21 -15
  45. package/dist/ui/dash/PostForm.js +22 -16
  46. package/dist/ui/dash/collections/CollectionForm.js +152 -0
  47. package/dist/ui/dash/collections/CollectionsListContent.js +68 -0
  48. package/dist/ui/dash/collections/ViewCollectionContent.js +96 -0
  49. package/dist/ui/dash/media/MediaListContent.js +166 -0
  50. package/dist/ui/dash/media/ViewMediaContent.js +212 -0
  51. package/dist/ui/dash/pages/LinkFormContent.js +130 -0
  52. package/dist/ui/dash/pages/UnifiedPagesContent.js +193 -0
  53. package/dist/ui/dash/settings/AccountContent.js +209 -0
  54. package/dist/ui/dash/settings/AppearanceContent.js +259 -0
  55. package/dist/ui/dash/settings/GeneralContent.js +536 -0
  56. package/dist/ui/dash/settings/SettingsNav.js +41 -0
  57. package/dist/ui/font-themes.js +36 -0
  58. package/dist/ui/layouts/BaseLayout.js +24 -2
  59. package/dist/ui/layouts/SiteLayout.js +47 -19
  60. package/package.json +1 -1
  61. package/src/app.tsx +95 -553
  62. package/src/auth.ts +4 -1
  63. package/src/client.ts +1 -0
  64. package/src/i18n/locales/en.po +240 -175
  65. package/src/i18n/locales/en.ts +1 -1
  66. package/src/i18n/locales/zh-Hans.po +240 -175
  67. package/src/i18n/locales/zh-Hans.ts +1 -1
  68. package/src/i18n/locales/zh-Hant.po +240 -175
  69. package/src/i18n/locales/zh-Hant.ts +1 -1
  70. package/src/lib/__tests__/config.test.ts +192 -0
  71. package/src/lib/__tests__/favicon.test.ts +151 -0
  72. package/src/lib/__tests__/image.test.ts +2 -6
  73. package/src/lib/__tests__/timezones.test.ts +61 -0
  74. package/src/lib/__tests__/view.test.ts +2 -2
  75. package/src/lib/avatar-upload.ts +165 -0
  76. package/src/lib/config.ts +47 -0
  77. package/src/lib/constants.ts +19 -11
  78. package/src/lib/favicon.ts +115 -0
  79. package/src/lib/image.ts +13 -21
  80. package/src/lib/media-helpers.ts +2 -2
  81. package/src/lib/navigation.ts +33 -2
  82. package/src/lib/render.tsx +15 -1
  83. package/src/lib/schemas.ts +39 -0
  84. package/src/lib/timezones.ts +325 -0
  85. package/src/lib/view.ts +1 -1
  86. package/src/routes/api/posts.ts +1 -1
  87. package/src/routes/api/upload.ts +2 -3
  88. package/src/routes/auth/reset.tsx +239 -0
  89. package/src/routes/auth/setup.tsx +189 -0
  90. package/src/routes/auth/signin.tsx +163 -0
  91. package/src/routes/dash/__tests__/settings-avatar.test.ts +89 -0
  92. package/src/routes/dash/collections.tsx +17 -366
  93. package/src/routes/dash/media.tsx +12 -414
  94. package/src/routes/dash/pages.tsx +8 -348
  95. package/src/routes/dash/redirects.tsx +20 -14
  96. package/src/routes/dash/settings.tsx +243 -534
  97. package/src/routes/feed/__tests__/rss.test.ts +141 -0
  98. package/src/routes/feed/rss.ts +3 -1
  99. package/src/routes/feed/sitemap.ts +4 -2
  100. package/src/routes/pages/featured.tsx +7 -1
  101. package/src/routes/pages/home.tsx +25 -2
  102. package/src/routes/pages/latest.tsx +59 -0
  103. package/src/services/post.ts +34 -66
  104. package/src/styles/components.css +0 -65
  105. package/src/styles/tokens.css +1 -1
  106. package/src/styles/ui.css +24 -40
  107. package/src/types/bindings.ts +30 -0
  108. package/src/types/config.ts +183 -0
  109. package/src/types/constants.ts +26 -0
  110. package/src/types/entities.ts +109 -0
  111. package/src/types/operations.ts +88 -0
  112. package/src/types/props.ts +115 -0
  113. package/src/types/views.ts +172 -0
  114. package/src/types.ts +8 -644
  115. package/src/ui/__tests__/font-themes.test.ts +34 -0
  116. package/src/ui/color-themes.ts +34 -34
  117. package/src/ui/compose/ComposeDialog.tsx +40 -21
  118. package/src/ui/dash/PageForm.tsx +25 -19
  119. package/src/ui/dash/PostForm.tsx +26 -20
  120. package/src/ui/dash/collections/CollectionForm.tsx +153 -0
  121. package/src/ui/dash/collections/CollectionsListContent.tsx +85 -0
  122. package/src/ui/dash/collections/ViewCollectionContent.tsx +92 -0
  123. package/src/ui/dash/media/MediaListContent.tsx +201 -0
  124. package/src/ui/dash/media/ViewMediaContent.tsx +208 -0
  125. package/src/ui/dash/pages/LinkFormContent.tsx +119 -0
  126. package/src/ui/dash/pages/UnifiedPagesContent.tsx +203 -0
  127. package/src/ui/dash/settings/AccountContent.tsx +176 -0
  128. package/src/ui/dash/settings/AppearanceContent.tsx +254 -0
  129. package/src/ui/dash/settings/GeneralContent.tsx +533 -0
  130. package/src/ui/dash/settings/SettingsNav.tsx +56 -0
  131. package/src/ui/font-themes.ts +54 -0
  132. package/src/ui/layouts/BaseLayout.tsx +17 -0
  133. package/src/ui/layouts/SiteLayout.tsx +45 -31
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Media grid list with upload UI
3
+ */
4
+
5
+ import { useLingui } from "@lingui/react/macro";
6
+ import type { Media } from "../../../types.js";
7
+ import { EmptyState } from "../index.js";
8
+ import {
9
+ getMediaUrl,
10
+ getImageUrl,
11
+ getPublicUrlForProvider,
12
+ } from "../../../lib/image.js";
13
+
14
+ function formatSize(bytes: number): string {
15
+ if (bytes < 1024) return `${bytes} B`;
16
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
17
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
18
+ }
19
+
20
+ function MediaCard({
21
+ media,
22
+ r2PublicUrl,
23
+ imageTransformUrl,
24
+ s3PublicUrl,
25
+ }: {
26
+ media: Media;
27
+ r2PublicUrl?: string;
28
+ imageTransformUrl?: string;
29
+ s3PublicUrl?: string;
30
+ }) {
31
+ const publicUrl = getPublicUrlForProvider(
32
+ media.provider,
33
+ r2PublicUrl,
34
+ s3PublicUrl,
35
+ );
36
+ const fullUrl = getMediaUrl(media.storageKey, publicUrl);
37
+ const thumbnailUrl = getImageUrl(fullUrl, imageTransformUrl, {
38
+ width: 300,
39
+ quality: 80,
40
+ format: "auto",
41
+ fit: "cover",
42
+ });
43
+ const isImage = media.mimeType.startsWith("image/");
44
+
45
+ return (
46
+ <div class="group relative" data-media-id={media.id}>
47
+ {isImage ? (
48
+ <button
49
+ type="button"
50
+ class="block w-full aspect-square bg-muted rounded-lg overflow-hidden border hover:border-primary cursor-pointer"
51
+ onclick={`document.getElementById('lightbox-img').src = '${fullUrl}'; document.getElementById('lightbox').showModal()`}
52
+ >
53
+ <img
54
+ src={thumbnailUrl}
55
+ alt={media.alt || media.originalName}
56
+ class="w-full h-full object-cover"
57
+ loading="lazy"
58
+ />
59
+ </button>
60
+ ) : (
61
+ <a
62
+ href={`/dash/media/${media.id}`}
63
+ class="block aspect-square bg-muted rounded-lg overflow-hidden border hover:border-primary"
64
+ >
65
+ <div class="w-full h-full flex items-center justify-center text-muted-foreground">
66
+ <span class="text-xs">{media.mimeType}</span>
67
+ </div>
68
+ </a>
69
+ )}
70
+ <a
71
+ href={`/dash/media/${media.id}`}
72
+ class="block mt-2 text-xs truncate hover:underline"
73
+ title={media.originalName}
74
+ >
75
+ {media.originalName}
76
+ </a>
77
+ <div class="text-xs text-muted-foreground">{formatSize(media.size)}</div>
78
+ </div>
79
+ );
80
+ }
81
+
82
+ export function MediaListContent({
83
+ mediaList,
84
+ r2PublicUrl,
85
+ imageTransformUrl,
86
+ s3PublicUrl,
87
+ }: {
88
+ mediaList: Media[];
89
+ r2PublicUrl?: string;
90
+ imageTransformUrl?: string;
91
+ s3PublicUrl?: string;
92
+ }) {
93
+ const { t } = useLingui();
94
+
95
+ const processingText = t({
96
+ message: "Processing...",
97
+ comment: "@context: Upload status - processing",
98
+ });
99
+ const uploadingText = t({
100
+ message: "Uploading...",
101
+ comment: "@context: Upload status - uploading",
102
+ });
103
+ const uploadText = t({
104
+ message: "Upload",
105
+ comment: "@context: Button to upload media file",
106
+ });
107
+ const errorText = t({
108
+ message: "Upload failed. Please try again.",
109
+ comment: "@context: Upload error message",
110
+ });
111
+
112
+ return (
113
+ <>
114
+ {/* Hidden form for Datastar-driven upload */}
115
+ <form
116
+ id="upload-form"
117
+ class="hidden"
118
+ enctype="multipart/form-data"
119
+ data-on:submit__prevent="@post('/api/upload', {contentType: 'form'})"
120
+ >
121
+ <input id="upload-file-input" type="file" name="file" />
122
+ </form>
123
+
124
+ {/* Header */}
125
+ <div class="flex items-center justify-between mb-6">
126
+ <h1 class="text-2xl font-semibold">
127
+ {t({ message: "Media", comment: "@context: Media main heading" })}
128
+ </h1>
129
+ <label class="btn cursor-pointer">
130
+ <span>{uploadText}</span>
131
+ <input
132
+ type="file"
133
+ class="hidden"
134
+ accept="image/*"
135
+ data-media-upload
136
+ data-text-processing={processingText}
137
+ data-text-uploading={uploadingText}
138
+ data-text-error={errorText}
139
+ />
140
+ </label>
141
+ </div>
142
+
143
+ {/* Upload instructions */}
144
+ <div class="card mb-6">
145
+ <section class="text-sm text-muted-foreground">
146
+ <p>
147
+ {t({
148
+ message:
149
+ "Images are automatically optimized: resized to max 1920px, converted to WebP, and metadata stripped.",
150
+ comment:
151
+ "@context: Media upload instructions - auto optimization",
152
+ })}
153
+ </p>
154
+ </section>
155
+ </div>
156
+
157
+ {/* Media grid or empty state */}
158
+ <div id="media-content">
159
+ {mediaList.length === 0 ? (
160
+ <div id="empty-state">
161
+ <EmptyState
162
+ message={t({
163
+ message: "No media uploaded yet.",
164
+ comment: "@context: Empty state message when no media exists",
165
+ })}
166
+ />
167
+ </div>
168
+ ) : (
169
+ <div
170
+ id="media-grid"
171
+ class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4"
172
+ >
173
+ {mediaList.map((m) => (
174
+ <MediaCard
175
+ key={m.id}
176
+ media={m}
177
+ r2PublicUrl={r2PublicUrl}
178
+ imageTransformUrl={imageTransformUrl}
179
+ s3PublicUrl={s3PublicUrl}
180
+ />
181
+ ))}
182
+ </div>
183
+ )}
184
+ </div>
185
+
186
+ {/* Lightbox */}
187
+ <dialog
188
+ id="lightbox"
189
+ class="p-0 m-auto bg-transparent backdrop:bg-black/80"
190
+ onclick="event.target === this && this.close()"
191
+ >
192
+ <img
193
+ id="lightbox-img"
194
+ src=""
195
+ alt=""
196
+ class="max-w-[90vw] max-h-[90vh] object-contain rounded-lg"
197
+ />
198
+ </dialog>
199
+ </>
200
+ );
201
+ }
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Single media detail view
3
+ */
4
+
5
+ import { useLingui } from "@lingui/react/macro";
6
+ import type { Media } from "../../../types.js";
7
+ import { DangerZone } from "../index.js";
8
+ import * as time from "../../../lib/time.js";
9
+ import {
10
+ getMediaUrl,
11
+ getImageUrl,
12
+ getPublicUrlForProvider,
13
+ } from "../../../lib/image.js";
14
+
15
+ function formatSize(bytes: number): string {
16
+ if (bytes < 1024) return `${bytes} B`;
17
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
18
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
19
+ }
20
+
21
+ export function ViewMediaContent({
22
+ media,
23
+ r2PublicUrl,
24
+ imageTransformUrl,
25
+ s3PublicUrl,
26
+ }: {
27
+ media: Media;
28
+ r2PublicUrl?: string;
29
+ imageTransformUrl?: string;
30
+ s3PublicUrl?: string;
31
+ }) {
32
+ const { t } = useLingui();
33
+ const publicUrl = getPublicUrlForProvider(
34
+ media.provider,
35
+ r2PublicUrl,
36
+ s3PublicUrl,
37
+ );
38
+ const url = getMediaUrl(media.storageKey, publicUrl);
39
+ const thumbnailUrl = getImageUrl(url, imageTransformUrl, {
40
+ width: 600,
41
+ quality: 85,
42
+ format: "auto",
43
+ });
44
+ const isImage = media.mimeType.startsWith("image/");
45
+
46
+ return (
47
+ <>
48
+ <div class="flex items-center justify-between mb-6">
49
+ <div>
50
+ <h1 class="text-2xl font-semibold">{media.originalName}</h1>
51
+ <p class="text-muted-foreground mt-1">
52
+ {formatSize(media.size)} · {media.mimeType} ·{" "}
53
+ {time.formatDate(media.createdAt)}
54
+ </p>
55
+ </div>
56
+ <a href="/dash/media" class="btn-outline">
57
+ {t({
58
+ message: "Back",
59
+ comment: "@context: Button to go back to media list",
60
+ })}
61
+ </a>
62
+ </div>
63
+
64
+ <div class="grid gap-6 md:grid-cols-2">
65
+ {/* Preview */}
66
+ <div class="card">
67
+ <header>
68
+ <h2>
69
+ {t({
70
+ message: "Preview",
71
+ comment: "@context: Media detail section - preview",
72
+ })}
73
+ </h2>
74
+ </header>
75
+ <section>
76
+ {isImage ? (
77
+ <>
78
+ <button
79
+ type="button"
80
+ class="cursor-pointer"
81
+ onclick={`document.getElementById('lightbox-img').src = '${url}'; document.getElementById('lightbox').showModal()`}
82
+ >
83
+ <img
84
+ src={thumbnailUrl}
85
+ alt={media.alt || media.originalName}
86
+ class="max-w-full rounded-lg hover:opacity-90 transition-opacity"
87
+ />
88
+ </button>
89
+ <p class="text-xs text-muted-foreground mt-2">
90
+ {t({
91
+ message: "Click image to view full size",
92
+ comment: "@context: Hint to click image for lightbox",
93
+ })}
94
+ </p>
95
+ </>
96
+ ) : (
97
+ <div class="aspect-video bg-muted rounded-lg flex items-center justify-center text-muted-foreground">
98
+ <span>{media.mimeType}</span>
99
+ </div>
100
+ )}
101
+ </section>
102
+ </div>
103
+
104
+ {/* Details */}
105
+ <div class="space-y-6">
106
+ <div class="card">
107
+ <header>
108
+ <h2>
109
+ {t({
110
+ message: "URL",
111
+ comment: "@context: Media detail section - URL",
112
+ })}
113
+ </h2>
114
+ </header>
115
+ <section>
116
+ <div class="flex items-center gap-2">
117
+ <input
118
+ type="text"
119
+ class="input flex-1 font-mono text-sm"
120
+ value={url}
121
+ readonly
122
+ />
123
+ <button
124
+ type="button"
125
+ class="btn-outline"
126
+ onclick={`navigator.clipboard.writeText('${url}')`}
127
+ >
128
+ {t({
129
+ message: "Copy",
130
+ comment: "@context: Button to copy URL to clipboard",
131
+ })}
132
+ </button>
133
+ </div>
134
+ <p class="text-xs text-muted-foreground mt-2">
135
+ {t({
136
+ message: "Use this URL to embed the media in your posts.",
137
+ comment: "@context: Media URL helper text",
138
+ })}
139
+ </p>
140
+ </section>
141
+ </div>
142
+
143
+ <div class="card">
144
+ <header>
145
+ <h2>
146
+ {t({
147
+ message: "Markdown",
148
+ comment: "@context: Media detail section - Markdown snippet",
149
+ })}
150
+ </h2>
151
+ </header>
152
+ <section>
153
+ <div class="flex items-center gap-2">
154
+ <input
155
+ type="text"
156
+ class="input flex-1 font-mono text-sm"
157
+ value={`![${media.alt || media.originalName}](${url})`}
158
+ readonly
159
+ />
160
+ <button
161
+ type="button"
162
+ class="btn-outline"
163
+ onclick={`navigator.clipboard.writeText('![${media.alt || media.originalName}](${url})')`}
164
+ >
165
+ {t({
166
+ message: "Copy",
167
+ comment: "@context: Button to copy Markdown to clipboard",
168
+ })}
169
+ </button>
170
+ </div>
171
+ </section>
172
+ </div>
173
+
174
+ {/* Delete */}
175
+ <DangerZone
176
+ actionLabel={t({
177
+ message: "Delete Media",
178
+ comment: "@context: Button to delete media",
179
+ })}
180
+ formAction={`/dash/media/${media.id}/delete`}
181
+ confirmMessage="Are you sure you want to delete this media?"
182
+ description={t({
183
+ message:
184
+ "Deleting this media will remove it permanently from storage.",
185
+ comment: "@context: Warning message before deleting media",
186
+ })}
187
+ />
188
+ </div>
189
+ </div>
190
+
191
+ {/* Lightbox */}
192
+ {isImage && (
193
+ <dialog
194
+ id="lightbox"
195
+ class="p-0 m-auto bg-transparent backdrop:bg-black/80"
196
+ onclick="event.target === this && this.close()"
197
+ >
198
+ <img
199
+ id="lightbox-img"
200
+ src=""
201
+ alt=""
202
+ class="max-w-[90vw] max-h-[90vh] object-contain rounded-lg"
203
+ />
204
+ </dialog>
205
+ )}
206
+ </>
207
+ );
208
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Link creation/editing form
3
+ */
4
+
5
+ import { useLingui } from "@lingui/react/macro";
6
+ import type { NavItem } from "../../../types.js";
7
+
8
+ export function LinkFormContent({
9
+ item,
10
+ isEdit,
11
+ }: {
12
+ item?: NavItem;
13
+ isEdit?: boolean;
14
+ }) {
15
+ const { t } = useLingui();
16
+ const title = isEdit
17
+ ? t({ message: "Edit Link", comment: "@context: Page heading" })
18
+ : t({ message: "New Link", comment: "@context: Page heading" });
19
+
20
+ const signals = JSON.stringify({
21
+ label: item?.label ?? "",
22
+ url: item?.url ?? "",
23
+ }).replace(/</g, "\\u003c");
24
+
25
+ const action = isEdit ? `/dash/pages/links/${item?.id}` : "/dash/pages/links";
26
+
27
+ return (
28
+ <>
29
+ <h1 class="text-2xl font-semibold mb-6">{title}</h1>
30
+
31
+ <form
32
+ data-signals={signals}
33
+ data-on:submit__prevent={`@post('${action}')`}
34
+ data-indicator="_loading"
35
+ class="flex flex-col gap-4 max-w-lg"
36
+ >
37
+ <div class="field">
38
+ <label class="label">
39
+ {t({
40
+ message: "Label",
41
+ comment: "@context: Navigation link form field",
42
+ })}
43
+ </label>
44
+ <input
45
+ type="text"
46
+ data-bind="label"
47
+ class="input"
48
+ placeholder="Home"
49
+ required
50
+ />
51
+ <p class="text-xs text-muted-foreground mt-1">
52
+ {t({
53
+ message: "Display text for the link",
54
+ comment: "@context: Navigation label help text",
55
+ })}
56
+ </p>
57
+ </div>
58
+
59
+ <div class="field">
60
+ <label class="label">
61
+ {t({
62
+ message: "URL",
63
+ comment: "@context: Navigation link form field",
64
+ })}
65
+ </label>
66
+ <input
67
+ type="text"
68
+ data-bind="url"
69
+ class="input"
70
+ placeholder="/archive or https://..."
71
+ required
72
+ />
73
+ <p class="text-xs text-muted-foreground mt-1">
74
+ {t({
75
+ message:
76
+ "Path (e.g. /archive) or full URL (e.g. https://example.com)",
77
+ comment: "@context: Navigation URL help text",
78
+ })}
79
+ </p>
80
+ </div>
81
+
82
+ <div class="flex gap-2">
83
+ <button type="submit" class="btn" data-attr:disabled="$_loading">
84
+ <svg
85
+ data-show="$_loading"
86
+ style="display:none"
87
+ class="animate-spin size-4"
88
+ xmlns="http://www.w3.org/2000/svg"
89
+ viewBox="0 0 24 24"
90
+ fill="none"
91
+ stroke="currentColor"
92
+ stroke-width="2"
93
+ stroke-linecap="round"
94
+ stroke-linejoin="round"
95
+ role="status"
96
+ >
97
+ <path d="M21 12a9 9 0 1 1-6.219-8.56" />
98
+ </svg>
99
+ {isEdit
100
+ ? t({
101
+ message: "Save Changes",
102
+ comment: "@context: Button to save edited navigation link",
103
+ })
104
+ : t({
105
+ message: "Create Link",
106
+ comment: "@context: Button to save new navigation link",
107
+ })}
108
+ </button>
109
+ <a href="/dash/pages" class="btn-outline">
110
+ {t({
111
+ message: "Cancel",
112
+ comment: "@context: Button to cancel form",
113
+ })}
114
+ </a>
115
+ </div>
116
+ </form>
117
+ </>
118
+ );
119
+ }