@glw907/cairn-cms 0.56.2 → 0.57.1
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/CHANGELOG.md +134 -0
- package/dist/components/AdminLayout.svelte +3 -0
- package/dist/components/CairnAdmin.svelte +8 -1
- package/dist/components/CairnAdmin.svelte.d.ts +2 -0
- package/dist/components/CairnMediaLibrary.svelte +949 -0
- package/dist/components/CairnMediaLibrary.svelte.d.ts +37 -0
- package/dist/components/EditPage.svelte +348 -7
- package/dist/components/EditPage.svelte.d.ts +2 -0
- package/dist/components/MarkdownEditor.svelte +283 -1
- package/dist/components/MarkdownEditor.svelte.d.ts +37 -1
- package/dist/components/MediaCaptureCard.svelte +135 -0
- package/dist/components/MediaCaptureCard.svelte.d.ts +40 -0
- package/dist/components/MediaFigureControl.svelte +247 -0
- package/dist/components/MediaFigureControl.svelte.d.ts +40 -0
- package/dist/components/MediaHeroField.svelte +578 -0
- package/dist/components/MediaHeroField.svelte.d.ts +75 -0
- package/dist/components/MediaInsertPopover.svelte +449 -0
- package/dist/components/MediaInsertPopover.svelte.d.ts +58 -0
- package/dist/components/MediaPicker.svelte +257 -0
- package/dist/components/MediaPicker.svelte.d.ts +41 -0
- package/dist/components/admin-icons.d.ts +12 -0
- package/dist/components/admin-icons.js +12 -0
- package/dist/components/cairn-admin.css +901 -9
- package/dist/components/client-ingest.d.ts +142 -0
- package/dist/components/client-ingest.js +297 -0
- package/dist/components/editor-media.d.ts +11 -0
- package/dist/components/editor-media.js +206 -0
- package/dist/components/editor-placeholder.d.ts +26 -0
- package/dist/components/editor-placeholder.js +166 -0
- package/dist/components/index.d.ts +1 -0
- package/dist/components/index.js +1 -0
- package/dist/components/markdown-directives.d.ts +12 -0
- package/dist/components/markdown-directives.js +42 -0
- package/dist/components/markdown-format.d.ts +89 -0
- package/dist/components/markdown-format.js +255 -0
- package/dist/components/media-upload-outcome.d.ts +52 -0
- package/dist/components/media-upload-outcome.js +48 -0
- package/dist/content/compose.js +3 -0
- package/dist/content/frontmatter.js +22 -0
- package/dist/content/manifest.d.ts +4 -0
- package/dist/content/manifest.js +41 -1
- package/dist/content/media-refs.d.ts +7 -0
- package/dist/content/media-refs.js +52 -0
- package/dist/content/schema.d.ts +5 -2
- package/dist/content/schema.js +17 -0
- package/dist/content/types.d.ts +64 -11
- package/dist/content/validate.js +31 -0
- package/dist/delivery/public-routes.d.ts +16 -0
- package/dist/delivery/public-routes.js +46 -3
- package/dist/delivery/seo-fields.js +7 -1
- package/dist/delivery/seo.d.ts +2 -0
- package/dist/delivery/seo.js +3 -0
- package/dist/doctor/checks-local.d.ts +1 -0
- package/dist/doctor/checks-local.js +21 -0
- package/dist/doctor/index.d.ts +3 -1
- package/dist/doctor/index.js +11 -2
- package/dist/doctor/types.d.ts +3 -0
- package/dist/doctor/wrangler-config.d.ts +3 -0
- package/dist/doctor/wrangler-config.js +20 -0
- package/dist/env.d.ts +19 -0
- package/dist/env.js +26 -0
- package/dist/index.d.ts +1 -1
- package/dist/log/events.d.ts +1 -1
- package/dist/media/config.d.ts +24 -0
- package/dist/media/config.js +69 -0
- package/dist/media/delivery-bucket.d.ts +34 -0
- package/dist/media/delivery-bucket.js +10 -0
- package/dist/media/index.d.ts +6 -0
- package/dist/media/index.js +13 -0
- package/dist/media/library-entry.d.ts +30 -0
- package/dist/media/library-entry.js +17 -0
- package/dist/media/manifest.d.ts +44 -0
- package/dist/media/manifest.js +105 -0
- package/dist/media/naming.d.ts +18 -0
- package/dist/media/naming.js +112 -0
- package/dist/media/reconcile.d.ts +36 -0
- package/dist/media/reconcile.js +45 -0
- package/dist/media/reference.d.ts +12 -0
- package/dist/media/reference.js +33 -0
- package/dist/media/sniff.d.ts +18 -0
- package/dist/media/sniff.js +106 -0
- package/dist/media/store.d.ts +25 -0
- package/dist/media/store.js +16 -0
- package/dist/media/transform-url.d.ts +26 -0
- package/dist/media/transform-url.js +38 -0
- package/dist/media/usage.d.ts +48 -0
- package/dist/media/usage.js +90 -0
- package/dist/render/pipeline.d.ts +2 -0
- package/dist/render/pipeline.js +13 -2
- package/dist/render/registry.js +3 -0
- package/dist/render/remark-figure.d.ts +4 -0
- package/dist/render/remark-figure.js +103 -0
- package/dist/render/resolve-media.d.ts +34 -0
- package/dist/render/resolve-media.js +78 -0
- package/dist/render/sanitize-schema.d.ts +4 -2
- package/dist/render/sanitize-schema.js +5 -3
- package/dist/sveltekit/admin-dispatch.d.ts +2 -0
- package/dist/sveltekit/admin-dispatch.js +5 -0
- package/dist/sveltekit/cairn-admin.d.ts +8 -1
- package/dist/sveltekit/cairn-admin.js +10 -2
- package/dist/sveltekit/content-routes.d.ts +77 -2
- package/dist/sveltekit/content-routes.js +470 -10
- package/dist/sveltekit/csrf.d.ts +16 -0
- package/dist/sveltekit/csrf.js +18 -0
- package/dist/sveltekit/guard.js +10 -3
- package/dist/sveltekit/index.d.ts +2 -1
- package/dist/sveltekit/index.js +1 -0
- package/dist/sveltekit/media-route.d.ts +12 -0
- package/dist/sveltekit/media-route.js +137 -0
- package/dist/vite/index.d.ts +3 -0
- package/dist/vite/index.js +7 -2
- package/package.json +7 -1
- package/src/lib/components/AdminLayout.svelte +3 -0
- package/src/lib/components/CairnAdmin.svelte +8 -1
- package/src/lib/components/CairnMediaLibrary.svelte +949 -0
- package/src/lib/components/EditPage.svelte +348 -7
- package/src/lib/components/MarkdownEditor.svelte +283 -1
- package/src/lib/components/MediaCaptureCard.svelte +135 -0
- package/src/lib/components/MediaFigureControl.svelte +247 -0
- package/src/lib/components/MediaHeroField.svelte +578 -0
- package/src/lib/components/MediaInsertPopover.svelte +449 -0
- package/src/lib/components/MediaPicker.svelte +257 -0
- package/src/lib/components/admin-icons.ts +12 -0
- package/src/lib/components/cairn-admin.css +37 -0
- package/src/lib/components/client-ingest.ts +380 -0
- package/src/lib/components/editor-media.ts +248 -0
- package/src/lib/components/editor-placeholder.ts +213 -0
- package/src/lib/components/index.ts +1 -0
- package/src/lib/components/markdown-directives.ts +46 -0
- package/src/lib/components/markdown-format.ts +307 -1
- package/src/lib/components/media-upload-outcome.ts +83 -0
- package/src/lib/content/compose.ts +3 -0
- package/src/lib/content/frontmatter.ts +20 -1
- package/src/lib/content/manifest.ts +44 -1
- package/src/lib/content/media-refs.ts +58 -0
- package/src/lib/content/schema.ts +31 -7
- package/src/lib/content/types.ts +80 -13
- package/src/lib/content/validate.ts +29 -1
- package/src/lib/delivery/public-routes.ts +52 -3
- package/src/lib/delivery/seo-fields.ts +6 -1
- package/src/lib/delivery/seo.ts +5 -0
- package/src/lib/doctor/checks-local.ts +22 -0
- package/src/lib/doctor/index.ts +21 -3
- package/src/lib/doctor/types.ts +3 -0
- package/src/lib/doctor/wrangler-config.ts +23 -0
- package/src/lib/env.ts +28 -0
- package/src/lib/index.ts +2 -0
- package/src/lib/log/events.ts +8 -1
- package/src/lib/media/config.ts +103 -0
- package/src/lib/media/delivery-bucket.ts +41 -0
- package/src/lib/media/index.ts +22 -0
- package/src/lib/media/library-entry.ts +58 -0
- package/src/lib/media/manifest.ts +122 -0
- package/src/lib/media/naming.ts +130 -0
- package/src/lib/media/reconcile.ts +79 -0
- package/src/lib/media/reference.ts +40 -0
- package/src/lib/media/sniff.ts +114 -0
- package/src/lib/media/store.ts +57 -0
- package/src/lib/media/transform-url.ts +58 -0
- package/src/lib/media/usage.ts +152 -0
- package/src/lib/render/pipeline.ts +17 -3
- package/src/lib/render/registry.ts +5 -0
- package/src/lib/render/remark-figure.ts +132 -0
- package/src/lib/render/resolve-media.ts +96 -0
- package/src/lib/render/sanitize-schema.ts +5 -3
- package/src/lib/sveltekit/admin-dispatch.ts +6 -1
- package/src/lib/sveltekit/cairn-admin.ts +13 -3
- package/src/lib/sveltekit/content-routes.ts +589 -12
- package/src/lib/sveltekit/csrf.ts +18 -0
- package/src/lib/sveltekit/guard.ts +12 -3
- package/src/lib/sveltekit/index.ts +6 -0
- package/src/lib/sveltekit/media-route.ts +158 -0
- package/src/lib/vite/index.ts +9 -2
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component
|
|
3
|
+
The hero image frontmatter field: the persistent details-panel field that sets a concept's lead
|
|
4
|
+
picture, the one image that both leads the page and becomes the social card. It edits the structured
|
|
5
|
+
value `{ src, alt, caption, decorative }` and writes it to four hidden form inputs the save path's
|
|
6
|
+
decode arm reads. cairn stays markdown-first, so this is a structured-data form field, never a
|
|
7
|
+
WYSIWYG canvas.
|
|
8
|
+
|
|
9
|
+
The field renders inside the edit form (the EditPage details loop). A nested <form> would break SSR,
|
|
10
|
+
so the field carries no <form> of its own: the working inputs in the dialog (the alt input, the
|
|
11
|
+
caption input) carry no name and never submit, and the committed value rides four named hidden
|
|
12
|
+
inputs (`<name>.src`, `<name>.alt`, `<name>.caption`, `<name>.decorative`). "Use this image" copies
|
|
13
|
+
the dialog's working state into those hidden inputs; the form's own Save commits them.
|
|
14
|
+
|
|
15
|
+
The decorative choice persists for the frontmatter hero because the hero value is an object with a
|
|
16
|
+
slot for it. A reload then tells a deliberately decorative hero apart from a left-blank alt, so a
|
|
17
|
+
decorative hero no longer trips the needs-alt notice. A decorative body image (``)
|
|
18
|
+
cannot persist the same choice, since markdown alt has no slot for it, so a decorative body image
|
|
19
|
+
still reads as needs-alt on reload.
|
|
20
|
+
|
|
21
|
+
At rest, when a hero is set, the field is one row at sibling weight: the resolved thumbnail, the
|
|
22
|
+
display name, an alt-status chip (Described, Needs alt, or Decorative, each a glyph plus a label,
|
|
23
|
+
never hue alone), and an Edit control, with the caption shown beneath as a read-only preview. Empty,
|
|
24
|
+
it is a slim labeled dropzone plus one plain line stating the image is also the social card; a real
|
|
25
|
+
drag-and-drop onto the dropzone routes a dropped image to the upload path.
|
|
26
|
+
|
|
27
|
+
Editing opens a native <dialog class="modal"> (the admin Dialog recipe: native focus trap and
|
|
28
|
+
Escape). Two views live inside it: the chooser (an upload-first button plus the MediaPicker combobox
|
|
29
|
+
below) and the placement view (a 16:9 crop preview, the describe-or-decorative alt radiogroup, the
|
|
30
|
+
caption input, and Replace/Remove as quiet text controls). The dialog reuses MediaPicker directly and
|
|
31
|
+
replicates MediaCaptureCard's alt radiogroup model rather than mounting MediaCaptureCard (which holds
|
|
32
|
+
its own <form>, illegal nested here, and a Name field a hero has no use for). Alt is debt, never a
|
|
33
|
+
save block; a decorative hero resolves alt to the empty string. The upload path mirrors the insert
|
|
34
|
+
popover's runUpload but resolves to this field, not an editor placeholder.
|
|
35
|
+
-->
|
|
36
|
+
<script lang="ts">
|
|
37
|
+
import { getContext, tick, untrack } from 'svelte';
|
|
38
|
+
import { CSRF_CONTEXT_KEY } from './csrf-context.js';
|
|
39
|
+
import MediaPicker, { type MediaLibraryEntry, type MediaSelection } from './MediaPicker.svelte';
|
|
40
|
+
import {
|
|
41
|
+
ingestFile,
|
|
42
|
+
buildUploadRequest,
|
|
43
|
+
sendUpload,
|
|
44
|
+
ingestFailureKind,
|
|
45
|
+
failureCard,
|
|
46
|
+
proposedNameFor,
|
|
47
|
+
firstImageFile,
|
|
48
|
+
} from './client-ingest.js';
|
|
49
|
+
import { deserialize } from '$app/forms';
|
|
50
|
+
import { uploadOutcome, type UploadEnvelope } from './media-upload-outcome.js';
|
|
51
|
+
import { parseMediaToken } from '../media/reference.js';
|
|
52
|
+
import { publicPath } from '../media/naming.js';
|
|
53
|
+
import type { MediaEntry } from '../media/manifest.js';
|
|
54
|
+
|
|
55
|
+
interface Props {
|
|
56
|
+
/** The field descriptor: the form input name base and the visible label. */
|
|
57
|
+
field: { name: string; label: string };
|
|
58
|
+
/** The initial committed value, from `data.frontmatter[field.name]`. */
|
|
59
|
+
value?: { src: string; alt: string; caption?: string; decorative?: boolean };
|
|
60
|
+
/** Whether the initial hero is an explicit decorative choice (an empty alt that is not debt).
|
|
61
|
+
* Defaults false; a fresh field with an empty alt reads as needs-alt. */
|
|
62
|
+
decorative?: boolean;
|
|
63
|
+
/** The merged committed-plus-uploaded media library, keyed by content hash. */
|
|
64
|
+
mediaLibrary: Record<string, MediaLibraryEntry>;
|
|
65
|
+
/** The concept the entry belongs to (the upload action's route param). */
|
|
66
|
+
conceptId: string;
|
|
67
|
+
/** The entry id (the upload action's route param). */
|
|
68
|
+
id: string;
|
|
69
|
+
/** Called with the server-owned record on a successful upload; the host merges it into the library
|
|
70
|
+
* and the save field, the same wiring the insert popover uses. */
|
|
71
|
+
onuploaded: (record: MediaEntry) => void;
|
|
72
|
+
/** Called when the committed value changes (a confirm or a remove), so the host sets fieldsDirty.
|
|
73
|
+
* The hidden-input writes do not fire the form's oninput, so the field signals dirty explicitly. */
|
|
74
|
+
ondirty: () => void;
|
|
75
|
+
/** Called once on mount and again whenever this hero's needs-alt status changes, with the current
|
|
76
|
+
* signal (a non-decorative hero with an empty alt is needs-alt). The host sums this with the body
|
|
77
|
+
* scanner's hits for the needs-alt notice. A frontmatter hero has no body offset, so it is
|
|
78
|
+
* reported from the field state, never routed through the body scanner. */
|
|
79
|
+
onneedsaltchange?: (needsAlt: boolean) => void;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let {
|
|
83
|
+
field,
|
|
84
|
+
value,
|
|
85
|
+
decorative: decorativeInitial = false,
|
|
86
|
+
mediaLibrary,
|
|
87
|
+
conceptId,
|
|
88
|
+
id,
|
|
89
|
+
onuploaded,
|
|
90
|
+
ondirty,
|
|
91
|
+
onneedsaltchange,
|
|
92
|
+
}: Props = $props();
|
|
93
|
+
|
|
94
|
+
// The CSRF token getter from the admin context (AdminLayout provides it). Undefined outside the
|
|
95
|
+
// shell, where the empty token fails the guard's check, the intended fail-closed signal.
|
|
96
|
+
const csrf = getContext<(() => string) | undefined>(CSRF_CONTEXT_KEY);
|
|
97
|
+
|
|
98
|
+
// A stable id base for the dialog's labelled regions.
|
|
99
|
+
const uid = $props.id();
|
|
100
|
+
const titleId = `cairn-hero-title-${uid}`;
|
|
101
|
+
const altNoteId = `cairn-hero-alt-note-${uid}`;
|
|
102
|
+
|
|
103
|
+
// The committed value the hidden inputs bind to. Seeded once from the prop; "Use this image" and
|
|
104
|
+
// Remove own it thereafter (untrack marks the read a deliberate one-time seed, not a reactive
|
|
105
|
+
// miss). An empty src is the empty state. A save reloads the page, which remounts with the fresh
|
|
106
|
+
// prop, so a later prop change is out of scope.
|
|
107
|
+
let committedSrc = $state(untrack(() => value?.src ?? ''));
|
|
108
|
+
let committedAlt = $state(untrack(() => value?.alt ?? ''));
|
|
109
|
+
let committedCaption = $state(untrack(() => value?.caption ?? ''));
|
|
110
|
+
// Whether the committed hero is an explicit decorative choice (an empty alt that is not debt).
|
|
111
|
+
let committedDecorative = $state(untrack(() => decorativeInitial));
|
|
112
|
+
|
|
113
|
+
// The resting/empty split keys off whether a src is set.
|
|
114
|
+
const hasHero = $derived(committedSrc.trim() !== '');
|
|
115
|
+
|
|
116
|
+
/** Resolve a media: src to its library entry through the content hash. Null when the token does not
|
|
117
|
+
* parse or the hash is not in the library. */
|
|
118
|
+
function entryForSrc(src: string): MediaLibraryEntry | null {
|
|
119
|
+
const ref = parseMediaToken(src);
|
|
120
|
+
if (!ref) return null;
|
|
121
|
+
return mediaLibrary[ref.hash] ?? null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// The resting row's resolved entry, thumbnail, and display name.
|
|
125
|
+
const committedEntry = $derived(hasHero ? entryForSrc(committedSrc) : null);
|
|
126
|
+
const committedThumb = $derived(
|
|
127
|
+
committedEntry
|
|
128
|
+
? publicPath(committedEntry.slug, committedEntry.hash, committedEntry.ext, 'slug')
|
|
129
|
+
: '',
|
|
130
|
+
);
|
|
131
|
+
const committedName = $derived(
|
|
132
|
+
committedEntry ? committedEntry.displayName || committedEntry.slug || committedEntry.hash : '',
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
// The resting alt-status: decorative is an explicit choice, an empty alt is needs-alt debt, and a
|
|
136
|
+
// non-empty alt is described.
|
|
137
|
+
type AltStatus = 'described' | 'needs-alt' | 'decorative';
|
|
138
|
+
const committedStatus = $derived<AltStatus>(
|
|
139
|
+
committedDecorative ? 'decorative' : committedAlt.trim() !== '' ? 'described' : 'needs-alt',
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
// Report this hero's needs-alt signal to the host: once on mount and again whenever the committed
|
|
143
|
+
// status changes. The host sums the signal with the body scanner's hits for the needs-alt notice.
|
|
144
|
+
// Only a set hero can be debt; an empty field (no src) reports false even though committedStatus
|
|
145
|
+
// reads 'needs-alt' by default.
|
|
146
|
+
const heroNeedsAlt = $derived(hasHero && committedStatus === 'needs-alt');
|
|
147
|
+
// Report the signal on mount and on every real change, but read the callback through untrack so a
|
|
148
|
+
// fresh callback identity (the host recreates the arrow on each of its own renders, which this very
|
|
149
|
+
// call triggers) does not re-run the effect and loop. The effect depends only on the signal value.
|
|
150
|
+
$effect(() => {
|
|
151
|
+
const signal = heroNeedsAlt;
|
|
152
|
+
untrack(() => onneedsaltchange?.(signal));
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// ---- the dialog ----
|
|
156
|
+
// The dialog element and which view it shows. 'chooser' picks or uploads; 'placement' captures alt
|
|
157
|
+
// and caption for a chosen image; null means the working selection is not yet made.
|
|
158
|
+
let dialog = $state<HTMLDialogElement | null>(null);
|
|
159
|
+
type View = 'chooser' | 'placement';
|
|
160
|
+
let view = $state<View>('chooser');
|
|
161
|
+
|
|
162
|
+
// The working selection in the placement view: the chosen ref, its resolved thumbnail, the alt
|
|
163
|
+
// mode and text, and the caption. Seeded on a pick or a successful upload, copied to the committed
|
|
164
|
+
// value only on "Use this image".
|
|
165
|
+
let workRef = $state('');
|
|
166
|
+
let workThumb = $state('');
|
|
167
|
+
let workAltMode = $state<'describe' | 'decorative' | null>(null);
|
|
168
|
+
let workAltText = $state('');
|
|
169
|
+
let workCaption = $state('');
|
|
170
|
+
|
|
171
|
+
// The upload transient state, surfaced in the dialog while an uploaded file is decoded and stored.
|
|
172
|
+
type Upload = { kind: 'idle' } | { kind: 'uploading' } | { kind: 'failed'; message: string; retry: () => void };
|
|
173
|
+
let upload = $state<Upload>({ kind: 'idle' });
|
|
174
|
+
|
|
175
|
+
let fileInput = $state<HTMLInputElement | null>(null);
|
|
176
|
+
// The describe-mode alt text input, bound so the needs-alt remediation path (focusAlt) can land
|
|
177
|
+
// the author's focus directly on it.
|
|
178
|
+
let altInput = $state<HTMLInputElement | null>(null);
|
|
179
|
+
|
|
180
|
+
/** Open the dialog to the chooser. Editing a set hero still leads with the placement view seeded
|
|
181
|
+
* from the committed value, so an author lands on the alt and caption they already wrote. */
|
|
182
|
+
function openDialog(initial: View) {
|
|
183
|
+
upload = { kind: 'idle' };
|
|
184
|
+
if (initial === 'placement' && hasHero) {
|
|
185
|
+
seedPlacementFromCommitted();
|
|
186
|
+
view = 'placement';
|
|
187
|
+
} else {
|
|
188
|
+
view = 'chooser';
|
|
189
|
+
}
|
|
190
|
+
dialog?.showModal();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function closeDialog() {
|
|
194
|
+
dialog?.close();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** The needs-alt remediation path the edit page's notice row calls: open the dialog to the
|
|
198
|
+
* placement view seeded from the committed value, switch a non-decorative hero into describe mode
|
|
199
|
+
* so the alt text input renders, and land focus on it. This is the "Add alt text" action, the
|
|
200
|
+
* frontmatter counterpart to the body notice's select-range jump (a hero has no body offset, so it
|
|
201
|
+
* focuses the field's own alt input rather than a source range). */
|
|
202
|
+
export async function focusAlt() {
|
|
203
|
+
openDialog('placement');
|
|
204
|
+
if (workAltMode !== 'decorative') {
|
|
205
|
+
workAltMode = 'describe';
|
|
206
|
+
}
|
|
207
|
+
await tick();
|
|
208
|
+
altInput?.focus();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Seed the placement working state from the committed value (the Edit path). */
|
|
212
|
+
function seedPlacementFromCommitted() {
|
|
213
|
+
workRef = committedSrc;
|
|
214
|
+
workThumb = committedThumb;
|
|
215
|
+
workCaption = committedCaption;
|
|
216
|
+
if (committedDecorative) {
|
|
217
|
+
workAltMode = 'decorative';
|
|
218
|
+
workAltText = '';
|
|
219
|
+
} else if (committedAlt.trim() !== '') {
|
|
220
|
+
workAltMode = 'describe';
|
|
221
|
+
workAltText = committedAlt;
|
|
222
|
+
} else {
|
|
223
|
+
workAltMode = null;
|
|
224
|
+
workAltText = '';
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Seed the placement working state from a picked library asset: the ref, the resolved thumbnail,
|
|
229
|
+
* and the manifest alt prefilled into describe mode when non-empty. */
|
|
230
|
+
function onPick(sel: MediaSelection) {
|
|
231
|
+
workRef = sel.ref;
|
|
232
|
+
workThumb = publicPath(sel.entry.slug, sel.entry.hash, sel.entry.ext, 'slug');
|
|
233
|
+
workCaption = '';
|
|
234
|
+
if (sel.alt.trim() !== '') {
|
|
235
|
+
workAltMode = 'describe';
|
|
236
|
+
workAltText = sel.alt;
|
|
237
|
+
} else {
|
|
238
|
+
workAltMode = null;
|
|
239
|
+
workAltText = '';
|
|
240
|
+
}
|
|
241
|
+
upload = { kind: 'idle' };
|
|
242
|
+
view = 'placement';
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Confirm the working selection into the committed hidden inputs, mark dirty, and close. */
|
|
246
|
+
function confirm() {
|
|
247
|
+
committedSrc = workRef;
|
|
248
|
+
committedDecorative = workAltMode === 'decorative';
|
|
249
|
+
committedAlt = workAltMode === 'describe' ? workAltText.trim() : '';
|
|
250
|
+
committedCaption = workCaption.trim();
|
|
251
|
+
ondirty();
|
|
252
|
+
closeDialog();
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Remove the hero: clear the committed value, mark dirty, and return to the empty state. */
|
|
256
|
+
function remove() {
|
|
257
|
+
committedSrc = '';
|
|
258
|
+
committedAlt = '';
|
|
259
|
+
committedCaption = '';
|
|
260
|
+
committedDecorative = false;
|
|
261
|
+
ondirty();
|
|
262
|
+
closeDialog();
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Go back to the chooser to replace the working image. */
|
|
266
|
+
function replace() {
|
|
267
|
+
upload = { kind: 'idle' };
|
|
268
|
+
view = 'chooser';
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ---- the upload path ----
|
|
272
|
+
// A chosen or dropped file routes here. Unlike the insert popover there is no editor body, so no
|
|
273
|
+
// placeholder and no open-risk-2 concern: the field shows a small loading state, and on success it
|
|
274
|
+
// seeds the placement view from the server record (empty alt to fill in).
|
|
275
|
+
async function runUpload(file: File) {
|
|
276
|
+
upload = { kind: 'uploading' };
|
|
277
|
+
const fail = (message: string) => {
|
|
278
|
+
upload = { kind: 'failed', message, retry: () => void runUpload(file) };
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
let ingested: Awaited<ReturnType<typeof ingestFile>>;
|
|
282
|
+
try {
|
|
283
|
+
ingested = await ingestFile(file);
|
|
284
|
+
} catch (err) {
|
|
285
|
+
fail(failureCard(ingestFailureKind(err)).message);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const { url, init } = buildUploadRequest({
|
|
290
|
+
conceptId,
|
|
291
|
+
id,
|
|
292
|
+
bytes: ingested.blob,
|
|
293
|
+
contentType: ingested.contentType,
|
|
294
|
+
csrf: csrf?.() ?? '',
|
|
295
|
+
filename: file.name,
|
|
296
|
+
// The hero alt is a frontmatter value set in the placement view, independent of the manifest
|
|
297
|
+
// alt, so the upload carries an empty manifest alt.
|
|
298
|
+
alt: '',
|
|
299
|
+
displayName: proposedNameFor(file.name) ?? stem(file.name),
|
|
300
|
+
width: ingested.width,
|
|
301
|
+
height: ingested.height,
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
let res: Response;
|
|
305
|
+
try {
|
|
306
|
+
res = await sendUpload(url, init);
|
|
307
|
+
} catch (err) {
|
|
308
|
+
fail(failureCard(ingestFailureKind(err)).message);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (res.type === 'opaqueredirect' || res.status === 0) {
|
|
313
|
+
fail('Your session has expired. Please sign in again to add an image.');
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
let outcome: ReturnType<typeof uploadOutcome>;
|
|
318
|
+
try {
|
|
319
|
+
outcome = uploadOutcome(deserialize(await res.text()) as UploadEnvelope);
|
|
320
|
+
} catch {
|
|
321
|
+
fail('The upload could not be completed. Please try again.');
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
if (outcome.kind === 'session-expired') {
|
|
325
|
+
fail('Your session has expired. Please sign in again to add an image.');
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
if (outcome.kind === 'failed') {
|
|
329
|
+
fail(
|
|
330
|
+
outcome.failure === 'generic'
|
|
331
|
+
? 'The upload could not be completed. Please try again.'
|
|
332
|
+
: failureCard(outcome.failure).message,
|
|
333
|
+
);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Success: merge the record up so the library resolves the new reference, then land on the
|
|
338
|
+
// placement view seeded from the record with an empty alt to fill in.
|
|
339
|
+
onuploaded(outcome.record);
|
|
340
|
+
const r = outcome.record;
|
|
341
|
+
workRef = outcome.reference;
|
|
342
|
+
workThumb = publicPath(r.slug, r.hash, r.ext, 'slug');
|
|
343
|
+
workAltMode = null;
|
|
344
|
+
workAltText = '';
|
|
345
|
+
workCaption = '';
|
|
346
|
+
upload = { kind: 'idle' };
|
|
347
|
+
view = 'placement';
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** The filename stem (extension dropped), the fallback display name for a generic filename. */
|
|
351
|
+
function stem(filename: string): string {
|
|
352
|
+
const dot = filename.lastIndexOf('.');
|
|
353
|
+
return (dot === -1 ? filename : filename.slice(0, dot)).trim() || filename;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function onChosenFile(e: Event) {
|
|
357
|
+
const input = e.currentTarget as HTMLInputElement;
|
|
358
|
+
const file = input.files?.[0];
|
|
359
|
+
if (file) void runUpload(file);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// The empty dropzone's drag-and-drop: a dropped image routes straight to the upload path, opening
|
|
363
|
+
// the dialog to the loading state. preventDefault stops the browser from navigating to the file.
|
|
364
|
+
function onDropzoneDrop(e: DragEvent) {
|
|
365
|
+
e.preventDefault();
|
|
366
|
+
const file = firstImageFile(e.dataTransfer ?? {});
|
|
367
|
+
if (file) {
|
|
368
|
+
view = 'chooser';
|
|
369
|
+
dialog?.showModal();
|
|
370
|
+
void runUpload(file);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
function onDropzoneDragover(e: DragEvent) {
|
|
374
|
+
e.preventDefault();
|
|
375
|
+
}
|
|
376
|
+
</script>
|
|
377
|
+
|
|
378
|
+
<div class="flex flex-col gap-1">
|
|
379
|
+
{#if hasHero}
|
|
380
|
+
<!-- The resting row: one row at sibling weight, then the caption preview beneath. -->
|
|
381
|
+
<span class="text-sm font-medium">{field.label}</span>
|
|
382
|
+
<div class="flex flex-col gap-1.5">
|
|
383
|
+
<div class="flex items-center gap-2.5">
|
|
384
|
+
<img
|
|
385
|
+
src={committedThumb}
|
|
386
|
+
alt={committedStatus === 'decorative' ? '' : committedAlt}
|
|
387
|
+
class="h-9 w-[3.25rem] flex-none rounded-field border border-[var(--cairn-card-border)] object-cover"
|
|
388
|
+
/>
|
|
389
|
+
<span class="flex min-w-0 flex-1 flex-col gap-0.5">
|
|
390
|
+
<span class="truncate text-[0.8125rem] font-medium">{committedName}</span>
|
|
391
|
+
{#if committedStatus === 'described'}
|
|
392
|
+
<span class="inline-flex w-max items-center gap-1 text-[0.6875rem] font-medium text-[var(--color-positive-ink)]">
|
|
393
|
+
<svg class="h-[0.6875rem] w-[0.6875rem]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5" /></svg>
|
|
394
|
+
<span>Described</span>
|
|
395
|
+
</span>
|
|
396
|
+
{:else if committedStatus === 'needs-alt'}
|
|
397
|
+
<span class="inline-flex w-max items-center gap-1 text-[0.6875rem] font-medium text-[var(--cairn-warning-ink)]">
|
|
398
|
+
<svg class="h-[0.6875rem] w-[0.6875rem]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" /><line x1="12" y1="9" x2="12" y2="13" /><line x1="12" y1="17" x2="12.01" y2="17" /></svg>
|
|
399
|
+
<span>Needs alt</span>
|
|
400
|
+
</span>
|
|
401
|
+
{:else}
|
|
402
|
+
<span class="inline-flex w-max items-center gap-1 text-[0.6875rem] font-medium text-[var(--color-muted)]">
|
|
403
|
+
<svg class="h-[0.6875rem] w-[0.6875rem]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9.88 9.88a3 3 0 1 0 4.24 4.24" /><path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68" /><path d="M6.61 6.61A13.526 13.526 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61" /><line x1="2" y1="2" x2="22" y2="22" /></svg>
|
|
404
|
+
<span>Decorative</span>
|
|
405
|
+
</span>
|
|
406
|
+
{/if}
|
|
407
|
+
</span>
|
|
408
|
+
<button
|
|
409
|
+
type="button"
|
|
410
|
+
class="flex-none bg-transparent p-1 text-[0.8125rem] font-medium text-[var(--color-primary)] underline underline-offset-2"
|
|
411
|
+
aria-haspopup="dialog"
|
|
412
|
+
onclick={() => openDialog('placement')}
|
|
413
|
+
>
|
|
414
|
+
{committedStatus === 'needs-alt' ? 'Add' : 'Edit'}
|
|
415
|
+
</button>
|
|
416
|
+
</div>
|
|
417
|
+
{#if committedCaption.trim() !== ''}
|
|
418
|
+
<p class="pl-[3.875rem] text-xs italic text-[var(--color-muted)]">{committedCaption}</p>
|
|
419
|
+
{:else}
|
|
420
|
+
<p class="pl-[3.875rem] text-xs text-[var(--color-muted)] opacity-80">No caption</p>
|
|
421
|
+
{/if}
|
|
422
|
+
{#if committedStatus === 'decorative' && committedCaption.trim() !== ''}
|
|
423
|
+
<!-- The one state worth a gentle note: a decorative hero with a caption. A soft inline line,
|
|
424
|
+
never a block. (Defensive; the resting empty-caption branch above usually wins.) -->
|
|
425
|
+
<p class="pl-[3.875rem] text-xs text-[var(--color-muted)]">
|
|
426
|
+
This hero is marked decorative, so screen readers skip it; the caption still shows to everyone.
|
|
427
|
+
</p>
|
|
428
|
+
{/if}
|
|
429
|
+
</div>
|
|
430
|
+
{:else}
|
|
431
|
+
<!-- The empty state: a slim labeled dropzone plus one plain unify line. -->
|
|
432
|
+
<span class="text-sm font-medium">{field.label}</span>
|
|
433
|
+
<button
|
|
434
|
+
type="button"
|
|
435
|
+
class="flex w-full items-center gap-2.5 rounded-field border border-dashed border-base-300 bg-base-100 px-3 py-2.5 text-left transition-colors hover:border-[color-mix(in_oklab,var(--color-primary)_45%,transparent)] hover:bg-[color-mix(in_oklab,var(--color-primary)_4%,transparent)] focus-visible:border-[color-mix(in_oklab,var(--color-primary)_70%,transparent)] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[color-mix(in_oklab,var(--color-primary)_70%,transparent)]"
|
|
436
|
+
aria-haspopup="dialog"
|
|
437
|
+
onclick={() => openDialog('chooser')}
|
|
438
|
+
ondrop={onDropzoneDrop}
|
|
439
|
+
ondragover={onDropzoneDragover}
|
|
440
|
+
>
|
|
441
|
+
<span class="flex h-7 w-7 flex-none items-center justify-center rounded-field bg-[color-mix(in_oklab,var(--color-primary)_10%,transparent)] text-[var(--color-primary)]" aria-hidden="true">
|
|
442
|
+
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" /><circle cx="9" cy="9" r="2" /><path d="m21 15-3.1-3.1a2 2 0 0 0-2.8 0L6 21" /></svg>
|
|
443
|
+
</span>
|
|
444
|
+
<span class="flex min-w-0 flex-col gap-px">
|
|
445
|
+
<span class="text-[0.8125rem] font-medium">Add a hero image</span>
|
|
446
|
+
<span class="text-[0.6875rem] text-[var(--color-muted)]">Drop an image here, or pick from the library</span>
|
|
447
|
+
</span>
|
|
448
|
+
</button>
|
|
449
|
+
<p class="text-[0.6875rem] leading-snug text-[var(--color-muted)]">
|
|
450
|
+
This image leads the page, and it is the picture shown when the post is shared.
|
|
451
|
+
</p>
|
|
452
|
+
{/if}
|
|
453
|
+
|
|
454
|
+
<!-- The committed value rides four named hidden inputs the save path's decode arm reads. They sit
|
|
455
|
+
inside the edit form (this component renders in the detailFields loop), so they submit; the
|
|
456
|
+
dialog's working inputs carry no name and never submit. The decorative input persists the
|
|
457
|
+
explicit decorative choice so a reload tells it apart from a left-blank alt. -->
|
|
458
|
+
<input type="hidden" name="{field.name}.src" value={committedSrc} />
|
|
459
|
+
<input type="hidden" name="{field.name}.alt" value={committedAlt} />
|
|
460
|
+
<input type="hidden" name="{field.name}.caption" value={committedCaption} />
|
|
461
|
+
<input type="hidden" name="{field.name}.decorative" value={committedDecorative ? 'true' : ''} />
|
|
462
|
+
</div>
|
|
463
|
+
|
|
464
|
+
<!-- The edit dialog: a native modal (focus trap and Escape for free). It sits at the end of the
|
|
465
|
+
component, outside the resting markup but still inside the edit form. A <dialog> is not a nested
|
|
466
|
+
<form>, and its working inputs carry no name, so nothing here submits with the edit form. -->
|
|
467
|
+
<dialog bind:this={dialog} class="modal" aria-labelledby={titleId}>
|
|
468
|
+
<div class="modal-box max-w-md">
|
|
469
|
+
<div class="mb-3 flex items-center justify-between gap-2">
|
|
470
|
+
<h2 id={titleId} class="text-[0.9375rem] font-semibold">
|
|
471
|
+
{view === 'chooser' ? 'Add a hero image' : 'Hero image'}
|
|
472
|
+
</h2>
|
|
473
|
+
<button type="button" class="btn btn-ghost btn-xs btn-square" aria-label="Close" onclick={closeDialog}>
|
|
474
|
+
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12" /></svg>
|
|
475
|
+
</button>
|
|
476
|
+
</div>
|
|
477
|
+
|
|
478
|
+
{#if upload.kind === 'uploading'}
|
|
479
|
+
<div class="flex flex-col items-center gap-3 py-8" role="status">
|
|
480
|
+
<span class="loading loading-spinner loading-md text-[var(--color-primary)]"></span>
|
|
481
|
+
<p class="text-sm text-[var(--color-muted)]">Adding your image...</p>
|
|
482
|
+
</div>
|
|
483
|
+
{:else if upload.kind === 'failed'}
|
|
484
|
+
<div class="flex flex-col gap-2" role="alert">
|
|
485
|
+
<p class="text-sm">{upload.message}</p>
|
|
486
|
+
<div class="flex justify-end gap-2">
|
|
487
|
+
<button type="button" class="btn btn-ghost btn-sm" onclick={replace}>Back</button>
|
|
488
|
+
<button type="button" class="btn btn-primary btn-sm" onclick={upload.retry}>Retry</button>
|
|
489
|
+
</div>
|
|
490
|
+
</div>
|
|
491
|
+
{:else if view === 'placement'}
|
|
492
|
+
<div class="flex flex-col gap-3.5">
|
|
493
|
+
<!-- The 16:9 crop preview: a real look at the image that leads the page and stands in as the
|
|
494
|
+
social card. -->
|
|
495
|
+
<div class="aspect-video w-full overflow-hidden rounded-box border border-[var(--cairn-card-border)]">
|
|
496
|
+
<img src={workThumb} alt="" class="h-full w-full object-cover" />
|
|
497
|
+
</div>
|
|
498
|
+
<!-- Replace and Remove are quiet text controls beneath the preview, never floated on it. -->
|
|
499
|
+
<div class="flex gap-3.5">
|
|
500
|
+
<button type="button" class="bg-transparent p-0 text-xs font-medium text-[var(--color-primary)] underline underline-offset-2" onclick={replace}>Replace</button>
|
|
501
|
+
<button type="button" class="bg-transparent p-0 text-xs font-medium text-[var(--color-muted)] underline underline-offset-2" onclick={remove}>Remove</button>
|
|
502
|
+
</div>
|
|
503
|
+
|
|
504
|
+
<!-- Alt as a describe-or-decorative radiogroup (the MediaCaptureCard model). The radios share a
|
|
505
|
+
component-unique name so native radiogroup keyboard navigation (one tab stop, arrow-key
|
|
506
|
+
cycling) works; the name is not one of the decode arm's three sub-fields
|
|
507
|
+
(<name>.src/.alt/.caption), so it is ignored on submit and never reaches the frontmatter.
|
|
508
|
+
Insert is never disabled for a missing alt. -->
|
|
509
|
+
<fieldset class="flex flex-col gap-2" role="radiogroup" aria-label="Alt text" aria-describedby={altNoteId}>
|
|
510
|
+
<legend class="text-sm font-medium">Alt text</legend>
|
|
511
|
+
<div class="flex gap-2">
|
|
512
|
+
<label class="flex flex-1 cursor-pointer items-center gap-1.5 rounded-field border px-2.5 py-1.5 text-[0.8125rem] {workAltMode === 'describe' ? 'border-[color-mix(in_oklab,var(--color-primary)_55%,transparent)] bg-[color-mix(in_oklab,var(--color-primary)_8%,transparent)] font-semibold text-[var(--color-primary)]' : 'border-base-300'}">
|
|
513
|
+
<input type="radio" class="radio radio-xs" name="cairn-hero-alt-{uid}" value="describe" bind:group={workAltMode} />
|
|
514
|
+
<span>Describe it</span>
|
|
515
|
+
</label>
|
|
516
|
+
<label class="flex flex-1 cursor-pointer items-center gap-1.5 rounded-field border px-2.5 py-1.5 text-[0.8125rem] {workAltMode === 'decorative' ? 'border-[color-mix(in_oklab,var(--color-primary)_55%,transparent)] bg-[color-mix(in_oklab,var(--color-primary)_8%,transparent)] font-semibold text-[var(--color-primary)]' : 'border-base-300'}">
|
|
517
|
+
<input type="radio" class="radio radio-xs" name="cairn-hero-alt-{uid}" value="decorative" bind:group={workAltMode} />
|
|
518
|
+
<span>Decorative</span>
|
|
519
|
+
</label>
|
|
520
|
+
</div>
|
|
521
|
+
{#if workAltMode === 'describe'}
|
|
522
|
+
<input
|
|
523
|
+
bind:this={altInput}
|
|
524
|
+
class="input input-sm w-full"
|
|
525
|
+
aria-label="Alt text description"
|
|
526
|
+
placeholder="A short description"
|
|
527
|
+
bind:value={workAltText}
|
|
528
|
+
/>
|
|
529
|
+
{/if}
|
|
530
|
+
<p id={altNoteId} class="text-xs text-[var(--color-muted)]">
|
|
531
|
+
A short description for screen readers. You can save without it and add it later.
|
|
532
|
+
</p>
|
|
533
|
+
</fieldset>
|
|
534
|
+
|
|
535
|
+
<label class="flex flex-col gap-1">
|
|
536
|
+
<span class="text-sm font-medium">Caption <span class="font-normal text-[var(--color-muted)]">(optional)</span></span>
|
|
537
|
+
<input class="input input-sm w-full" bind:value={workCaption} aria-label="Caption" />
|
|
538
|
+
<span class="text-xs text-[var(--color-muted)]">Shown under the hero if the template uses it. This is not the alt text.</span>
|
|
539
|
+
</label>
|
|
540
|
+
|
|
541
|
+
<p class="text-[0.6875rem] leading-snug text-[var(--color-muted)]">
|
|
542
|
+
This image is also the picture shown when the post is shared to social.
|
|
543
|
+
</p>
|
|
544
|
+
|
|
545
|
+
<div class="flex justify-end gap-2">
|
|
546
|
+
<button type="button" class="btn btn-ghost btn-sm" onclick={closeDialog}>Cancel</button>
|
|
547
|
+
<button type="button" class="btn btn-primary btn-sm" onclick={confirm}>Use this image</button>
|
|
548
|
+
</div>
|
|
549
|
+
</div>
|
|
550
|
+
{:else}
|
|
551
|
+
<!-- The chooser: upload first, the picker combobox below. -->
|
|
552
|
+
<div class="flex flex-col gap-3">
|
|
553
|
+
<div class="flex flex-col items-center gap-1.5 rounded-box border border-dashed border-base-300 px-4 py-4 text-center text-[var(--color-muted)]">
|
|
554
|
+
<span class="text-[var(--color-primary)]" aria-hidden="true">
|
|
555
|
+
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><path d="M17 8l-5-5-5 5" /><path d="M12 3v12" /></svg>
|
|
556
|
+
</span>
|
|
557
|
+
<span class="text-sm font-medium text-base-content">Drop an image, or upload</span>
|
|
558
|
+
<span class="text-xs">PNG, JPEG, WebP, or HEIC. We convert HEIC for you.</span>
|
|
559
|
+
<button type="button" class="btn btn-primary btn-sm mt-1" onclick={() => fileInput?.click()}>Choose a file</button>
|
|
560
|
+
<input
|
|
561
|
+
bind:this={fileInput}
|
|
562
|
+
type="file"
|
|
563
|
+
accept="image/*"
|
|
564
|
+
class="sr-only"
|
|
565
|
+
aria-label="Choose an image to upload"
|
|
566
|
+
onchange={onChosenFile}
|
|
567
|
+
/>
|
|
568
|
+
</div>
|
|
569
|
+
<p class="text-center text-[0.6875rem] uppercase tracking-[0.08em] text-[var(--color-muted)]">or pick from the library</p>
|
|
570
|
+
<MediaPicker library={mediaLibrary} onselect={onPick} />
|
|
571
|
+
</div>
|
|
572
|
+
{/if}
|
|
573
|
+
</div>
|
|
574
|
+
<!-- The light-dismiss backdrop. A bare <button>, not the DaisyUI <form method="dialog"> backdrop:
|
|
575
|
+
this dialog renders inside the edit <form> (the field is in the detailFields loop), and a
|
|
576
|
+
nested <form> would break SSR. tabindex -1 keeps it out of the native focus order. -->
|
|
577
|
+
<button type="button" class="modal-backdrop" tabindex="-1" aria-label="Close" onclick={closeDialog}></button>
|
|
578
|
+
</dialog>
|