mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Refactor NIP-71 form handling and update edit modal
This commit is contained in:
1 parent
3573b7a287
commit
cb18615c1b
4 files changed
+1355
-514
No files matched your search
@@ -73,6 +73,567 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- NOTE: Keep this NIP-71 markup in sync with the upload and revert modals. -->
|
||||
<div
|
||||
class="space-y-4 rounded-lg border border-gray-800 bg-black/30 p-4"
|
||||
id="editNip71EventDetails"
|
||||
data-nip71-section="event-details"
|
||||
>
|
||||
<fieldset
|
||||
id="editNip71KindFieldset"
|
||||
class="space-y-2"
|
||||
data-nip71-fieldset="kind"
|
||||
>
|
||||
<legend class="text-sm font-semibold text-gray-200">
|
||||
NIP-71 event type
|
||||
</legend>
|
||||
<p class="text-xs text-gray-400">
|
||||
Choose the video format. <span class="font-medium text-gray-300">Kind 21</span> is a full video, while
|
||||
<span class="font-medium text-gray-300">Kind 22</span> is a short.
|
||||
</p>
|
||||
<div
|
||||
class="flex flex-wrap gap-4"
|
||||
role="radiogroup"
|
||||
aria-labelledby="editNip71KindFieldset"
|
||||
>
|
||||
<label class="flex items-center gap-2 text-sm text-gray-200">
|
||||
<input
|
||||
type="radio"
|
||||
name="editNip71Kind"
|
||||
id="editNip71KindLong"
|
||||
value="21"
|
||||
checked
|
||||
data-nip71-input="kind"
|
||||
class="h-4 w-4 border-gray-600 bg-gray-800 text-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
Normal video (kind 21)
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm text-gray-200">
|
||||
<input
|
||||
type="radio"
|
||||
name="editNip71Kind"
|
||||
id="editNip71KindShort"
|
||||
value="22"
|
||||
data-nip71-input="kind"
|
||||
class="h-4 w-4 border-gray-600 bg-gray-800 text-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
Short video (kind 22)
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2" data-nip71-grid="metadata">
|
||||
<div>
|
||||
<label for="editNip71PublishedAt" class="block text-sm font-medium text-gray-200">
|
||||
Published at
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
id="editNip71PublishedAt"
|
||||
data-nip71-input="published_at"
|
||||
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-400">
|
||||
Set the canonical publish time for cross-client ordering.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="editNip71AltText" class="block text-sm font-medium text-gray-200">
|
||||
Alt text
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="editNip71AltText"
|
||||
data-nip71-input="alt"
|
||||
placeholder="Describe the visuals for screen readers"
|
||||
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-400">
|
||||
Plain-language fallback shown when the media cannot load.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="editNip71Duration" class="block text-sm font-medium text-gray-200">
|
||||
Duration (seconds)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="editNip71Duration"
|
||||
min="0"
|
||||
step="1"
|
||||
data-nip71-input="duration"
|
||||
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-400">
|
||||
Provide the runtime in whole seconds for timeline scrubbing.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="editNip71ContentWarning" class="block text-sm font-medium text-gray-200">
|
||||
Content warning
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="editNip71ContentWarning"
|
||||
data-nip71-input="content-warning"
|
||||
placeholder="e.g., Flashing lights"
|
||||
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-400">
|
||||
Leave blank if the video is safe for all audiences.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="editNip71Summary" class="block text-sm font-medium text-gray-200">
|
||||
Summary
|
||||
</label>
|
||||
<textarea
|
||||
id="editNip71Summary"
|
||||
rows="3"
|
||||
data-nip71-input="summary"
|
||||
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
|
||||
placeholder="Short synopsis used as the Nostr content field"
|
||||
></textarea>
|
||||
<p class="mt-1 text-xs text-gray-400">
|
||||
This becomes the event content. Keep it concise for Nostr readers.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<fieldset
|
||||
id="editNip71TagsFieldset"
|
||||
class="space-y-6 rounded-lg border border-gray-800 bg-black/30 p-4"
|
||||
data-nip71-section="tags"
|
||||
>
|
||||
<legend class="text-sm font-semibold text-gray-200">NIP-71 tags</legend>
|
||||
<p class="text-xs text-gray-400">
|
||||
Use the controls below to describe media variants, captions, chapters, participants, and references.
|
||||
</p>
|
||||
|
||||
<section class="space-y-4" aria-labelledby="editNip71ImetaLabel" data-nip71-repeater="imeta">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 id="editNip71ImetaLabel" class="text-sm font-medium text-gray-200">Image metadata (imeta)</h3>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-gray-700 px-3 py-1 text-xs font-semibold text-gray-200 hover:border-gray-500 hover:text-white"
|
||||
data-nip71-add="imeta"
|
||||
aria-describedby="editNip71ImetaHelp"
|
||||
>
|
||||
Add variant
|
||||
</button>
|
||||
</div>
|
||||
<p id="editNip71ImetaHelp" class="text-xs text-gray-400">
|
||||
Provide dimensions, MIME type, and resource links for each variant. Nested repeaters let you attach multiple
|
||||
<code>image</code>, <code>fallback</code>, or <code>service</code> values.
|
||||
</p>
|
||||
<div id="editNip71ImetaList" class="space-y-4" data-nip71-list="imeta"></div>
|
||||
<template id="editNip71ImetaTemplate" data-nip71-template="imeta">
|
||||
<article class="space-y-3 rounded-md border border-gray-800 bg-gray-900/60 p-4" data-nip71-entry="imeta">
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-300">
|
||||
MIME type (<code>m</code>)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
data-nip71-field="m"
|
||||
placeholder="video/mp4"
|
||||
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
<p class="mt-1 text-[11px] text-gray-500">RFC 2046 media type for this encoding.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-300">
|
||||
Pixel dimensions (<code>dim</code>)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
data-nip71-field="dim"
|
||||
placeholder="1920x1080"
|
||||
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
<p class="mt-1 text-[11px] text-gray-500">Formatted as widthxheight.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-300">
|
||||
Streaming URL (<code>url</code>)
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
data-nip71-field="url"
|
||||
placeholder="https://cdn.example.com/variant.m3u8"
|
||||
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
<p class="mt-1 text-[11px] text-gray-500">HTTPS variant URL shared across clients.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-300">
|
||||
Magnet (<code>x</code>)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
data-nip71-field="x"
|
||||
placeholder="magnet:?xt=urn:btih:..."
|
||||
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
<p class="mt-1 text-[11px] text-gray-500">Optional magnet pointer if the variant is seeded via P2P.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3" data-nip71-nested="image">
|
||||
<div class="flex items-center justify-between">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wide text-gray-400">Image values</h4>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-gray-700 px-2 py-1 text-[11px] text-gray-200 hover:border-gray-500 hover:text-white"
|
||||
data-nip71-nested-add="image"
|
||||
>
|
||||
Add image URL
|
||||
</button>
|
||||
</div>
|
||||
<div class="space-y-2" data-nip71-nested-list="image"></div>
|
||||
<template data-nip71-nested-template="image">
|
||||
<div class="flex items-center gap-2" data-nip71-nested-entry="image">
|
||||
<input
|
||||
type="url"
|
||||
placeholder="https://cdn.example.com/poster.jpg"
|
||||
data-nip71-nested-field="image"
|
||||
class="flex-1 rounded-md border border-gray-700 bg-gray-800 px-3 py-2 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-gray-700 px-2 py-1 text-[11px] text-gray-300 hover:border-gray-500 hover:text-white"
|
||||
data-nip71-remove="nested"
|
||||
aria-label="Remove image URL"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3" data-nip71-nested="fallback">
|
||||
<div class="flex items-center justify-between">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wide text-gray-400">Fallback URLs</h4>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-gray-700 px-2 py-1 text-[11px] text-gray-200 hover:border-gray-500 hover:text-white"
|
||||
data-nip71-nested-add="fallback"
|
||||
>
|
||||
Add fallback
|
||||
</button>
|
||||
</div>
|
||||
<div class="space-y-2" data-nip71-nested-list="fallback"></div>
|
||||
<template data-nip71-nested-template="fallback">
|
||||
<div class="flex items-center gap-2" data-nip71-nested-entry="fallback">
|
||||
<input
|
||||
type="url"
|
||||
placeholder="https://cdn.backup.net/video.mp4"
|
||||
data-nip71-nested-field="fallback"
|
||||
class="flex-1 rounded-md border border-gray-700 bg-gray-800 px-3 py-2 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-gray-700 px-2 py-1 text-[11px] text-gray-300 hover:border-gray-500 hover:text-white"
|
||||
data-nip71-remove="nested"
|
||||
aria-label="Remove fallback URL"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3" data-nip71-nested="service">
|
||||
<div class="flex items-center justify-between">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wide text-gray-400">Services</h4>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-gray-700 px-2 py-1 text-[11px] text-gray-200 hover:border-gray-500 hover:text-white"
|
||||
data-nip71-nested-add="service"
|
||||
>
|
||||
Add service
|
||||
</button>
|
||||
</div>
|
||||
<div class="space-y-2" data-nip71-nested-list="service"></div>
|
||||
<template data-nip71-nested-template="service">
|
||||
<div class="flex items-center gap-2" data-nip71-nested-entry="service">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="https://api.example.com"
|
||||
data-nip71-nested-field="service"
|
||||
class="flex-1 rounded-md border border-gray-700 bg-gray-800 px-3 py-2 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-gray-700 px-2 py-1 text-[11px] text-gray-300 hover:border-gray-500 hover:text-white"
|
||||
data-nip71-remove="nested"
|
||||
aria-label="Remove service entry"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-gray-700 px-2 py-1 text-[11px] text-gray-300 hover:border-gray-500 hover:text-white"
|
||||
data-nip71-remove="imeta"
|
||||
>
|
||||
Remove variant
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4" aria-labelledby="editNip71TextTracksLabel" data-nip71-repeater="text-track">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 id="editNip71TextTracksLabel" class="text-sm font-medium text-gray-200">
|
||||
Caption & subtitle tracks (text-track)
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-gray-700 px-3 py-1 text-xs font-semibold text-gray-200 hover:border-gray-500 hover:text-white"
|
||||
data-nip71-add="text-track"
|
||||
aria-describedby="editNip71TextTracksHelp"
|
||||
>
|
||||
Add track
|
||||
</button>
|
||||
</div>
|
||||
<p id="editNip71TextTracksHelp" class="text-xs text-gray-400">
|
||||
Supply caption or subtitle URLs along with file type and language so players can surface accessibility features.
|
||||
</p>
|
||||
<div id="editNip71TextTrackList" class="space-y-4" data-nip71-list="text-track"></div>
|
||||
<template id="editNip71TextTrackTemplate" data-nip71-template="text-track">
|
||||
<article class="space-y-3 rounded-md border border-gray-800 bg-gray-900/60 p-4" data-nip71-entry="text-track">
|
||||
<div class="grid gap-3 md:grid-cols-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-300">Track URL</label>
|
||||
<input
|
||||
type="url"
|
||||
data-nip71-field="url"
|
||||
placeholder="https://cdn.example.com/subs.vtt"
|
||||
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-300">Type</label>
|
||||
<input
|
||||
type="text"
|
||||
data-nip71-field="type"
|
||||
placeholder="text/vtt"
|
||||
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-300">Language</label>
|
||||
<input
|
||||
type="text"
|
||||
data-nip71-field="language"
|
||||
placeholder="en"
|
||||
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-gray-700 px-2 py-1 text-[11px] text-gray-300 hover:border-gray-500 hover:text-white"
|
||||
data-nip71-remove="text-track"
|
||||
>
|
||||
Remove track
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4" aria-labelledby="editNip71SegmentsLabel" data-nip71-repeater="segment">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 id="editNip71SegmentsLabel" class="text-sm font-medium text-gray-200">Chapters & segments (segment)</h3>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-gray-700 px-3 py-1 text-xs font-semibold text-gray-200 hover:border-gray-500 hover:text-white"
|
||||
data-nip71-add="segment"
|
||||
aria-describedby="editNip71SegmentsHelp"
|
||||
>
|
||||
Add segment
|
||||
</button>
|
||||
</div>
|
||||
<p id="editNip71SegmentsHelp" class="text-xs text-gray-400">
|
||||
Outline chapters with start and end times, titles, and optional thumbnails.
|
||||
</p>
|
||||
<div id="editNip71SegmentList" class="space-y-4" data-nip71-list="segment"></div>
|
||||
<template id="editNip71SegmentTemplate" data-nip71-template="segment">
|
||||
<article class="space-y-3 rounded-md border border-gray-800 bg-gray-900/60 p-4" data-nip71-entry="segment">
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-300">Start (seconds)</label>
|
||||
<input
|
||||
type="number"
|
||||
data-nip71-field="start"
|
||||
min="0"
|
||||
step="1"
|
||||
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-300">End (seconds)</label>
|
||||
<input
|
||||
type="number"
|
||||
data-nip71-field="end"
|
||||
min="0"
|
||||
step="1"
|
||||
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-300">Title</label>
|
||||
<input
|
||||
type="text"
|
||||
data-nip71-field="title"
|
||||
placeholder="Chapter title"
|
||||
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-300">Thumbnail URL</label>
|
||||
<input
|
||||
type="url"
|
||||
data-nip71-field="thumbnail"
|
||||
placeholder="https://cdn.example.com/frame.jpg"
|
||||
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-gray-700 px-2 py-1 text-[11px] text-gray-300 hover:border-gray-500 hover:text-white"
|
||||
data-nip71-remove="segment"
|
||||
>
|
||||
Remove segment
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4" aria-labelledby="editNip71HashtagsLabel" data-nip71-repeater="t">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 id="editNip71HashtagsLabel" class="text-sm font-medium text-gray-200">Hashtags (<code>t</code>)</h3>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-gray-700 px-3 py-1 text-xs font-semibold text-gray-200 hover:border-gray-500 hover:text-white"
|
||||
data-nip71-add="t"
|
||||
>
|
||||
Add hashtag
|
||||
</button>
|
||||
</div>
|
||||
<div id="editNip71HashtagList" class="space-y-2" data-nip71-list="t"></div>
|
||||
<template id="editNip71HashtagTemplate" data-nip71-template="t">
|
||||
<div class="flex items-center gap-2" data-nip71-entry="t">
|
||||
<input
|
||||
type="text"
|
||||
data-nip71-field="value"
|
||||
placeholder="#nostr"
|
||||
class="flex-1 rounded-md border border-gray-700 bg-gray-800 px-3 py-2 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-gray-700 px-2 py-1 text-[11px] text-gray-300 hover:border-gray-500 hover:text-white"
|
||||
data-nip71-remove="t"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4" aria-labelledby="editNip71ParticipantsLabel" data-nip71-repeater="p">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 id="editNip71ParticipantsLabel" class="text-sm font-medium text-gray-200">Participants (<code>p</code>)</h3>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-gray-700 px-3 py-1 text-xs font-semibold text-gray-200 hover:border-gray-500 hover:text-white"
|
||||
data-nip71-add="p"
|
||||
>
|
||||
Add participant
|
||||
</button>
|
||||
</div>
|
||||
<div id="editNip71ParticipantList" class="space-y-3" data-nip71-list="p"></div>
|
||||
<template id="editNip71ParticipantTemplate" data-nip71-template="p">
|
||||
<article class="space-y-3 rounded-md border border-gray-800 bg-gray-900/60 p-4" data-nip71-entry="p">
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-300">Participant pubkey</label>
|
||||
<input
|
||||
type="text"
|
||||
data-nip71-field="pubkey"
|
||||
placeholder="npub1... or hex"
|
||||
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-300">Relay hint</label>
|
||||
<input
|
||||
type="text"
|
||||
data-nip71-field="relay"
|
||||
placeholder="wss://relay.example.com"
|
||||
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-gray-700 px-2 py-1 text-[11px] text-gray-300 hover:border-gray-500 hover:text-white"
|
||||
data-nip71-remove="p"
|
||||
>
|
||||
Remove participant
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4" aria-labelledby="editNip71ReferencesLabel" data-nip71-repeater="r">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 id="editNip71ReferencesLabel" class="text-sm font-medium text-gray-200">Reference links (<code>r</code>)</h3>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-gray-700 px-3 py-1 text-xs font-semibold text-gray-200 hover:border-gray-500 hover:text-white"
|
||||
data-nip71-add="r"
|
||||
>
|
||||
Add reference
|
||||
</button>
|
||||
</div>
|
||||
<div id="editNip71ReferenceList" class="space-y-2" data-nip71-list="r"></div>
|
||||
<template id="editNip71ReferenceTemplate" data-nip71-template="r">
|
||||
<div class="flex items-center gap-2" data-nip71-entry="r">
|
||||
<input
|
||||
type="url"
|
||||
data-nip71-field="url"
|
||||
placeholder="https://example.com/context"
|
||||
class="flex-1 rounded-md border border-gray-700 bg-gray-800 px-3 py-2 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-gray-700 px-2 py-1 text-[11px] text-gray-300 hover:border-gray-500 hover:text-white"
|
||||
data-nip71-remove="r"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</fieldset>
|
||||
|
||||
<div class="space-y-1">
|
||||
<label for="editVideoUrl" class="block text-sm font-medium text-gray-200">
|
||||
Hosted video URL (https)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { extractMagnetHints, normalizeAndAugmentMagnet } from "../../magnet.js";
|
||||
import { Nip71FormManager } from "./nip71FormManager.js";
|
||||
|
||||
export class EditModal {
|
||||
constructor({
|
||||
@@ -57,6 +58,11 @@ export class EditModal {
|
||||
this.isVisible = false;
|
||||
this.eventsBound = false;
|
||||
this.loadPromise = null;
|
||||
|
||||
this.nip71FormManager = new Nip71FormManager();
|
||||
this.nip71SectionKey = "edit";
|
||||
this.originalNip71Metadata = null;
|
||||
this.originalNip71MetadataJson = null;
|
||||
}
|
||||
|
||||
addEventListener(type, listener, options) {
|
||||
@@ -163,6 +169,9 @@ export class EditModal {
|
||||
if (this.visibility.helper) {
|
||||
this.visibility.helperDefault = this.visibility.helper.textContent || "";
|
||||
}
|
||||
|
||||
const nip71Context = this.form || context;
|
||||
this.nip71FormManager.registerSection(this.nip71SectionKey, nip71Context);
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
@@ -232,11 +241,16 @@ export class EditModal {
|
||||
this.emit("video:edit-visibility-change", detail);
|
||||
});
|
||||
}
|
||||
|
||||
this.nip71FormManager.bindSection(this.nip71SectionKey);
|
||||
}
|
||||
|
||||
reset() {
|
||||
if (!this.root) {
|
||||
this.activeVideo = null;
|
||||
this.nip71FormManager.resetSection(this.nip71SectionKey);
|
||||
this.originalNip71Metadata = null;
|
||||
this.originalNip71MetadataJson = null;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -277,6 +291,9 @@ export class EditModal {
|
||||
|
||||
this.handleIsPrivateChange({ emit: false });
|
||||
|
||||
this.nip71FormManager.resetSection(this.nip71SectionKey);
|
||||
this.originalNip71Metadata = null;
|
||||
this.originalNip71MetadataJson = null;
|
||||
this.activeVideo = null;
|
||||
}
|
||||
|
||||
@@ -306,6 +323,13 @@ export class EditModal {
|
||||
};
|
||||
|
||||
this.applyVideoToForm(editContext);
|
||||
this.nip71FormManager.resetSection(this.nip71SectionKey);
|
||||
if (video.nip71 && typeof video.nip71 === "object") {
|
||||
this.nip71FormManager.hydrateSection(this.nip71SectionKey, video.nip71);
|
||||
}
|
||||
const initialNip71 = this.nip71FormManager.collectSection(this.nip71SectionKey);
|
||||
this.originalNip71Metadata = this.cloneNip71Metadata(initialNip71);
|
||||
this.originalNip71MetadataJson = JSON.stringify(this.originalNip71Metadata);
|
||||
this.activeVideo = editContext;
|
||||
|
||||
if (this.root) {
|
||||
@@ -641,6 +665,17 @@ export class EditModal {
|
||||
this.emit("video:edit-visibility-change", detail);
|
||||
}
|
||||
|
||||
cloneNip71Metadata(metadata) {
|
||||
if (metadata == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(metadata));
|
||||
} catch (error) {
|
||||
return metadata;
|
||||
}
|
||||
}
|
||||
|
||||
submit() {
|
||||
if (!this.activeVideo || !this.root) {
|
||||
this.showError("No video selected for editing.");
|
||||
@@ -715,6 +750,20 @@ export class EditModal {
|
||||
typeof original.enableComments === "boolean" ? original.enableComments : true;
|
||||
const originalIsPrivate = original.isPrivate === true;
|
||||
|
||||
const currentNip71 = this.cloneNip71Metadata(
|
||||
this.nip71FormManager.collectSection(this.nip71SectionKey)
|
||||
);
|
||||
const hasImetaSource = Array.isArray(currentNip71?.imeta)
|
||||
? currentNip71.imeta.some((variant) => {
|
||||
if (!variant || typeof variant !== "object") {
|
||||
return false;
|
||||
}
|
||||
const hasUrl = typeof variant.url === "string" && variant.url.trim();
|
||||
const hasMagnet = typeof variant.x === "string" && variant.x.trim();
|
||||
return Boolean(hasUrl || hasMagnet);
|
||||
})
|
||||
: false;
|
||||
|
||||
let finalEnableComments = originalEnableComments;
|
||||
if (commentsInput) {
|
||||
if (commentsInput.disabled) {
|
||||
@@ -733,8 +782,10 @@ export class EditModal {
|
||||
}
|
||||
}
|
||||
|
||||
if (!finalTitle || (!finalUrl && !finalMagnet)) {
|
||||
this.showError("Title and at least one of URL or Magnet is required.");
|
||||
if (!finalTitle || (!finalUrl && !finalMagnet && !hasImetaSource)) {
|
||||
this.showError(
|
||||
"Title and at least one of URL, Magnet, or a NIP-71 media variant is required."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -775,6 +826,11 @@ export class EditModal {
|
||||
isPrivate: finalIsPrivate,
|
||||
};
|
||||
|
||||
const currentNip71Json = JSON.stringify(currentNip71);
|
||||
const nip71Edited = currentNip71Json !== this.originalNip71MetadataJson;
|
||||
updatedData.nip71 = currentNip71;
|
||||
updatedData.nip71Edited = nip71Edited;
|
||||
|
||||
const originalEvent = {
|
||||
id: this.sanitizers.text(original.id || ""),
|
||||
pubkey: this.sanitizers.text(original.pubkey || ""),
|
||||
|
||||
+13
-512
@@ -1,3 +1,7 @@
|
||||
// NOTE: Keep the Upload, Edit, and Revert modals in lockstep when updating NIP-71 form features.
|
||||
|
||||
import { Nip71FormManager } from "./nip71FormManager.js";
|
||||
|
||||
export class UploadModal {
|
||||
constructor({
|
||||
authService,
|
||||
@@ -69,10 +73,7 @@ export class UploadModal {
|
||||
this.r2ZoneIdInput = null;
|
||||
this.r2BaseDomainInput = null;
|
||||
|
||||
this.nip71 = {
|
||||
custom: null,
|
||||
cloudflare: null,
|
||||
};
|
||||
this.nip71FormManager = new Nip71FormManager();
|
||||
}
|
||||
|
||||
addEventListener(type, listener, options) {
|
||||
@@ -210,244 +211,8 @@ export class UploadModal {
|
||||
this.r2ZoneIdInput = context.querySelector("#r2ZoneId") || null;
|
||||
this.r2BaseDomainInput = context.querySelector("#r2BaseDomain") || null;
|
||||
|
||||
this.nip71.custom = this.cacheNip71Section(this.customSection);
|
||||
this.nip71.cloudflare = this.cacheNip71Section(this.cloudflareSection);
|
||||
}
|
||||
|
||||
cacheNip71Section(section) {
|
||||
if (!section) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const repeaters = {};
|
||||
["imeta", "text-track", "segment", "t", "p", "r"].forEach((key) => {
|
||||
repeaters[key] = this.cacheNip71Repeater(section, key);
|
||||
});
|
||||
|
||||
return {
|
||||
root: section,
|
||||
kindInputs: Array.from(section.querySelectorAll('[data-nip71-input="kind"]')),
|
||||
publishedAtInput:
|
||||
section.querySelector('[data-nip71-input="published_at"]') || null,
|
||||
altInput: section.querySelector('[data-nip71-input="alt"]') || null,
|
||||
durationInput:
|
||||
section.querySelector('[data-nip71-input="duration"]') || null,
|
||||
contentWarningInput:
|
||||
section.querySelector('[data-nip71-input="content-warning"]') || null,
|
||||
summaryInput: section.querySelector('[data-nip71-input="summary"]') || null,
|
||||
repeaters,
|
||||
handlers: {},
|
||||
};
|
||||
}
|
||||
|
||||
cacheNip71Repeater(section, key) {
|
||||
if (!section || !key) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const repeaterRoot = section.querySelector(`[data-nip71-repeater="${key}"]`);
|
||||
if (!repeaterRoot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
root: repeaterRoot,
|
||||
list: repeaterRoot.querySelector(`[data-nip71-list="${key}"]`) || null,
|
||||
template:
|
||||
repeaterRoot.querySelector(`[data-nip71-template="${key}"]`) || null,
|
||||
};
|
||||
}
|
||||
|
||||
getNip71Store(mode) {
|
||||
if (!mode || !this.nip71) {
|
||||
return null;
|
||||
}
|
||||
return this.nip71[mode] || null;
|
||||
}
|
||||
|
||||
bindNip71Events(mode) {
|
||||
const store = this.getNip71Store(mode);
|
||||
if (!store?.root) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (store.handlers?.click) {
|
||||
try {
|
||||
store.root.removeEventListener("click", store.handlers.click);
|
||||
} catch (error) {
|
||||
console.warn("[UploadModal] Failed to detach previous NIP-71 handler", error);
|
||||
}
|
||||
}
|
||||
|
||||
const handleClick = (event) => {
|
||||
const addTrigger = event.target?.closest?.("[data-nip71-add]");
|
||||
if (addTrigger && store.root.contains(addTrigger)) {
|
||||
event.preventDefault();
|
||||
this.handleNip71Add(mode, addTrigger.dataset?.nip71Add || "");
|
||||
return;
|
||||
}
|
||||
|
||||
const nestedAddTrigger = event.target?.closest?.("[data-nip71-nested-add]");
|
||||
if (nestedAddTrigger && store.root.contains(nestedAddTrigger)) {
|
||||
event.preventDefault();
|
||||
this.handleNip71NestedAdd(nestedAddTrigger);
|
||||
return;
|
||||
}
|
||||
|
||||
const removeTrigger = event.target?.closest?.("[data-nip71-remove]");
|
||||
if (removeTrigger && store.root.contains(removeTrigger)) {
|
||||
event.preventDefault();
|
||||
this.handleNip71Remove(mode, removeTrigger);
|
||||
}
|
||||
};
|
||||
|
||||
store.root.addEventListener("click", handleClick);
|
||||
store.handlers = { ...(store.handlers || {}), click: handleClick };
|
||||
}
|
||||
|
||||
handleNip71Add(mode, key) {
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
const entry = this.addNip71RepeaterEntry(mode, key);
|
||||
if (entry) {
|
||||
this.focusFirstField(entry, "[data-nip71-field], [data-nip71-nested-field]");
|
||||
}
|
||||
}
|
||||
|
||||
handleNip71NestedAdd(trigger) {
|
||||
if (!trigger) {
|
||||
return;
|
||||
}
|
||||
const nestedKey = trigger.dataset?.nip71NestedAdd || "";
|
||||
if (!nestedKey) {
|
||||
return;
|
||||
}
|
||||
const container = trigger.closest(`[data-nip71-nested="${nestedKey}"]`);
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
const entry = this.addNip71NestedEntry(container, nestedKey);
|
||||
if (entry) {
|
||||
this.focusFirstField(
|
||||
entry,
|
||||
`[data-nip71-nested-field="${nestedKey}"]`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
handleNip71Remove(mode, trigger) {
|
||||
if (!trigger) {
|
||||
return;
|
||||
}
|
||||
const targetKey = trigger.dataset?.nip71Remove || "";
|
||||
if (!targetKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (targetKey === "nested") {
|
||||
const nestedEntry = trigger.closest("[data-nip71-nested-entry]");
|
||||
this.removeNip71NestedEntry(nestedEntry);
|
||||
return;
|
||||
}
|
||||
|
||||
this.removeNip71RepeaterEntry(mode, targetKey, trigger);
|
||||
}
|
||||
|
||||
addNip71RepeaterEntry(mode, key) {
|
||||
const store = this.getNip71Store(mode);
|
||||
if (!store?.repeaters || !key) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const repeater = store.repeaters[key];
|
||||
if (!repeater?.list || !repeater?.template) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const templateNode = repeater.template;
|
||||
const fragment = templateNode.content
|
||||
? templateNode.content.cloneNode(true)
|
||||
: templateNode.cloneNode(true);
|
||||
|
||||
const entry =
|
||||
fragment.querySelector?.(`[data-nip71-entry="${key}"]`) ||
|
||||
fragment.firstElementChild ||
|
||||
null;
|
||||
|
||||
repeater.list.appendChild(fragment);
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
removeNip71RepeaterEntry(mode, key, trigger) {
|
||||
if (!key || !trigger) {
|
||||
return;
|
||||
}
|
||||
|
||||
const store = this.getNip71Store(mode);
|
||||
if (!store?.repeaters?.[key]?.list) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = trigger.closest(`[data-nip71-entry="${key}"]`);
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.dataset?.nip71Primary === "true") {
|
||||
this.resetNip71Entry(entry);
|
||||
return;
|
||||
}
|
||||
|
||||
entry.remove();
|
||||
}
|
||||
|
||||
addNip71NestedEntry(container, nestedKey) {
|
||||
if (!container || !nestedKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const list = container.querySelector(
|
||||
`[data-nip71-nested-list="${nestedKey}"]`
|
||||
);
|
||||
const template = container.querySelector(
|
||||
`[data-nip71-nested-template="${nestedKey}"]`
|
||||
);
|
||||
|
||||
if (!list || !template) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fragment = template.content
|
||||
? template.content.cloneNode(true)
|
||||
: template.cloneNode(true);
|
||||
|
||||
const entry =
|
||||
fragment.querySelector?.(`[data-nip71-nested-entry="${nestedKey}"]`) ||
|
||||
fragment.firstElementChild ||
|
||||
null;
|
||||
|
||||
list.appendChild(fragment);
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
removeNip71NestedEntry(entry) {
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
entry.remove();
|
||||
}
|
||||
|
||||
focusFirstField(container, selector) {
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
const target = container.querySelector(selector);
|
||||
if (target?.focus) {
|
||||
target.focus();
|
||||
}
|
||||
this.nip71FormManager.registerSection("custom", this.customSection);
|
||||
this.nip71FormManager.registerSection("cloudflare", this.cloudflareSection);
|
||||
}
|
||||
|
||||
buildAutoGeneratedImetaVariant(file) {
|
||||
@@ -470,270 +235,6 @@ export class UploadModal {
|
||||
};
|
||||
}
|
||||
|
||||
collectNip71Metadata(mode) {
|
||||
const store = this.getNip71Store(mode);
|
||||
if (!store) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const kindInput = Array.isArray(store.kindInputs)
|
||||
? store.kindInputs.find((input) => input?.checked)
|
||||
: null;
|
||||
let kind = null;
|
||||
if (kindInput?.value != null) {
|
||||
const parsed = Number(kindInput.value);
|
||||
kind = Number.isFinite(parsed) ? parsed : String(kindInput.value).trim();
|
||||
}
|
||||
|
||||
const summary = this.getTrimmedValue(store.summaryInput);
|
||||
const publishedAt = this.getTrimmedValue(store.publishedAtInput);
|
||||
const alt = this.getTrimmedValue(store.altInput);
|
||||
|
||||
let duration = null;
|
||||
if (store.durationInput) {
|
||||
const value = store.durationInput.value;
|
||||
if (value !== "" && value != null) {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed)) {
|
||||
duration = parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const contentWarning = this.getTrimmedValue(store.contentWarningInput);
|
||||
|
||||
const imeta = this.collectNip71RepeaterValues(mode, "imeta", (entry) => {
|
||||
const variant = {
|
||||
m: this.getNip71FieldValue(entry, "m"),
|
||||
dim: this.getNip71FieldValue(entry, "dim"),
|
||||
url: this.getNip71FieldValue(entry, "url"),
|
||||
x: this.getNip71FieldValue(entry, "x"),
|
||||
image: this.collectNip71NestedValues(entry, "image"),
|
||||
fallback: this.collectNip71NestedValues(entry, "fallback"),
|
||||
service: this.collectNip71NestedValues(entry, "service"),
|
||||
};
|
||||
|
||||
const hasContent =
|
||||
variant.m ||
|
||||
variant.dim ||
|
||||
variant.url ||
|
||||
variant.x ||
|
||||
variant.image.length > 0 ||
|
||||
variant.fallback.length > 0 ||
|
||||
variant.service.length > 0;
|
||||
|
||||
return hasContent ? variant : null;
|
||||
});
|
||||
|
||||
const textTracks = this.collectNip71RepeaterValues(
|
||||
mode,
|
||||
"text-track",
|
||||
(entry) => {
|
||||
const track = {
|
||||
url: this.getNip71FieldValue(entry, "url"),
|
||||
type: this.getNip71FieldValue(entry, "type"),
|
||||
language: this.getNip71FieldValue(entry, "language"),
|
||||
};
|
||||
|
||||
const hasContent = track.url || track.type || track.language;
|
||||
return hasContent ? track : null;
|
||||
}
|
||||
);
|
||||
|
||||
const segments = this.collectNip71RepeaterValues(mode, "segment", (entry) => {
|
||||
const segment = {
|
||||
start: this.getNip71FieldValue(entry, "start"),
|
||||
end: this.getNip71FieldValue(entry, "end"),
|
||||
title: this.getNip71FieldValue(entry, "title"),
|
||||
thumbnail: this.getNip71FieldValue(entry, "thumbnail"),
|
||||
};
|
||||
|
||||
const hasContent =
|
||||
segment.start || segment.end || segment.title || segment.thumbnail;
|
||||
return hasContent ? segment : null;
|
||||
});
|
||||
|
||||
const hashtags = this.collectNip71RepeaterValues(mode, "t", (entry) => {
|
||||
const value = this.getNip71FieldValue(entry, "value");
|
||||
return value || null;
|
||||
});
|
||||
|
||||
const participants = this.collectNip71RepeaterValues(mode, "p", (entry) => {
|
||||
const participant = {
|
||||
pubkey: this.getNip71FieldValue(entry, "pubkey"),
|
||||
relay: this.getNip71FieldValue(entry, "relay"),
|
||||
};
|
||||
|
||||
const hasContent = participant.pubkey || participant.relay;
|
||||
return hasContent ? participant : null;
|
||||
});
|
||||
|
||||
const references = this.collectNip71RepeaterValues(mode, "r", (entry) => {
|
||||
const url = this.getNip71FieldValue(entry, "url");
|
||||
return url || null;
|
||||
});
|
||||
|
||||
return {
|
||||
kind,
|
||||
summary,
|
||||
publishedAt,
|
||||
alt,
|
||||
duration,
|
||||
contentWarning,
|
||||
imeta,
|
||||
textTracks,
|
||||
segments,
|
||||
hashtags,
|
||||
participants,
|
||||
references,
|
||||
};
|
||||
}
|
||||
|
||||
collectNip71RepeaterValues(mode, key, mapFn) {
|
||||
const store = this.getNip71Store(mode);
|
||||
const repeater = store?.repeaters?.[key];
|
||||
if (!repeater?.list) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const entries = Array.from(
|
||||
repeater.list.querySelectorAll(`[data-nip71-entry="${key}"]`)
|
||||
);
|
||||
|
||||
const results = [];
|
||||
entries.forEach((entry) => {
|
||||
const value = typeof mapFn === "function" ? mapFn(entry) : null;
|
||||
if (value == null) {
|
||||
return;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
if (value.trim()) {
|
||||
results.push(value.trim());
|
||||
}
|
||||
return;
|
||||
}
|
||||
results.push(value);
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
collectNip71NestedValues(entry, nestedKey) {
|
||||
if (!entry || !nestedKey) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const container = entry.querySelector(`[data-nip71-nested="${nestedKey}"]`);
|
||||
if (!container) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const fields = Array.from(
|
||||
container.querySelectorAll(`[data-nip71-nested-field="${nestedKey}"]`)
|
||||
);
|
||||
|
||||
return fields
|
||||
.map((field) => this.getTrimmedValue(field))
|
||||
.filter((value) => Boolean(value));
|
||||
}
|
||||
|
||||
getNip71FieldValue(entry, field) {
|
||||
if (!entry || !field) {
|
||||
return "";
|
||||
}
|
||||
const element = entry.querySelector(`[data-nip71-field="${field}"]`);
|
||||
return this.getTrimmedValue(element);
|
||||
}
|
||||
|
||||
getTrimmedValue(element) {
|
||||
if (!element) {
|
||||
return "";
|
||||
}
|
||||
const { value } = element;
|
||||
if (typeof value === "number") {
|
||||
return Number.isFinite(value) ? String(value).trim() : "";
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value.trim();
|
||||
}
|
||||
return String(value ?? "").trim();
|
||||
}
|
||||
|
||||
resetNip71Metadata(mode) {
|
||||
const store = this.getNip71Store(mode);
|
||||
if (!store) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(store.kindInputs)) {
|
||||
store.kindInputs.forEach((input) => {
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
input.checked = Boolean(input.defaultChecked);
|
||||
});
|
||||
}
|
||||
|
||||
if (store.summaryInput) {
|
||||
store.summaryInput.value = "";
|
||||
}
|
||||
if (store.publishedAtInput) {
|
||||
store.publishedAtInput.value = "";
|
||||
}
|
||||
if (store.altInput) {
|
||||
store.altInput.value = "";
|
||||
}
|
||||
if (store.durationInput) {
|
||||
store.durationInput.value = "";
|
||||
}
|
||||
if (store.contentWarningInput) {
|
||||
store.contentWarningInput.value = "";
|
||||
}
|
||||
|
||||
if (store.repeaters) {
|
||||
Object.entries(store.repeaters).forEach(([key, repeater]) => {
|
||||
if (!repeater?.list) {
|
||||
return;
|
||||
}
|
||||
const entries = Array.from(
|
||||
repeater.list.querySelectorAll(`[data-nip71-entry="${key}"]`)
|
||||
);
|
||||
entries.forEach((entry) => {
|
||||
if (entry.dataset?.nip71Primary === "true") {
|
||||
this.resetNip71Entry(entry);
|
||||
} else {
|
||||
entry.remove();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
resetNip71Entry(entry) {
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
const fields = entry.querySelectorAll("[data-nip71-field]");
|
||||
fields.forEach((field) => {
|
||||
if (field) {
|
||||
field.value = "";
|
||||
}
|
||||
});
|
||||
|
||||
const nestedContainers = entry.querySelectorAll("[data-nip71-nested]");
|
||||
nestedContainers.forEach((container) => {
|
||||
if (!container?.dataset?.nip71Nested) {
|
||||
return;
|
||||
}
|
||||
const list = container.querySelector(
|
||||
`[data-nip71-nested-list="${container.dataset.nip71Nested}"]`
|
||||
);
|
||||
if (list) {
|
||||
list.innerHTML = "";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
if (this.closeButton) {
|
||||
this.closeButton.addEventListener("click", () => {
|
||||
@@ -790,8 +291,8 @@ export class UploadModal {
|
||||
});
|
||||
}
|
||||
|
||||
this.bindNip71Events("custom");
|
||||
this.bindNip71Events("cloudflare");
|
||||
this.nip71FormManager.bindSection("custom");
|
||||
this.nip71FormManager.bindSection("cloudflare");
|
||||
}
|
||||
|
||||
registerR2Subscriptions() {
|
||||
@@ -952,7 +453,7 @@ export class UploadModal {
|
||||
payload.isPrivate = !!this.customFormInputs.isPrivate.checked;
|
||||
}
|
||||
|
||||
const nip71Metadata = this.collectNip71Metadata("custom");
|
||||
const nip71Metadata = this.nip71FormManager.collectSection("custom");
|
||||
if (nip71Metadata) {
|
||||
payload.nip71 = nip71Metadata;
|
||||
}
|
||||
@@ -1132,7 +633,7 @@ export class UploadModal {
|
||||
if (this.cloudflareEnableCommentsInput)
|
||||
this.cloudflareEnableCommentsInput.checked = true;
|
||||
if (this.cloudflareFileInput) this.cloudflareFileInput.value = "";
|
||||
this.resetNip71Metadata("cloudflare");
|
||||
this.nip71FormManager.resetSection("cloudflare");
|
||||
this.updateCloudflareProgress(Number.NaN);
|
||||
}
|
||||
|
||||
@@ -1149,7 +650,7 @@ export class UploadModal {
|
||||
this.customFormInputs.enableComments.checked = true;
|
||||
if (this.customFormInputs.isPrivate)
|
||||
this.customFormInputs.isPrivate.checked = false;
|
||||
this.resetNip71Metadata("custom");
|
||||
this.nip71FormManager.resetSection("custom");
|
||||
}
|
||||
|
||||
async loadR2Settings() {
|
||||
@@ -1217,7 +718,7 @@ export class UploadModal {
|
||||
: true,
|
||||
};
|
||||
|
||||
const nip71Metadata = this.collectNip71Metadata("cloudflare");
|
||||
const nip71Metadata = this.nip71FormManager.collectSection("cloudflare");
|
||||
if (nip71Metadata) {
|
||||
const imetaList = Array.isArray(nip71Metadata.imeta)
|
||||
? [...nip71Metadata.imeta]
|
||||
|
||||
@@ -0,0 +1,723 @@
|
||||
// NOTE: Keep the Upload, Edit, and Revert modals in lockstep when updating NIP-71 form features.
|
||||
|
||||
const REPEATER_KEYS = ["imeta", "text-track", "segment", "t", "p", "r"];
|
||||
|
||||
export class Nip71FormManager {
|
||||
constructor({ defaultFocusSelector } = {}) {
|
||||
this.defaultFocusSelector =
|
||||
typeof defaultFocusSelector === "string"
|
||||
? defaultFocusSelector
|
||||
: "[data-nip71-field], [data-nip71-nested-field]";
|
||||
this.sections = new Map();
|
||||
}
|
||||
|
||||
registerSection(key, root) {
|
||||
if (!key) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!root) {
|
||||
this.sections.delete(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
const store = this.cacheSection(root);
|
||||
this.sections.set(key, store);
|
||||
return store;
|
||||
}
|
||||
|
||||
bindSection(key) {
|
||||
const store = this.getSection(key);
|
||||
if (!store?.root) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (store.handlers?.click) {
|
||||
try {
|
||||
store.root.removeEventListener("click", store.handlers.click);
|
||||
} catch (error) {
|
||||
console.warn("[Nip71FormManager] Failed to detach previous handler", error);
|
||||
}
|
||||
}
|
||||
|
||||
const handleClick = (event) => {
|
||||
const addTrigger = event.target?.closest?.("[data-nip71-add]");
|
||||
if (addTrigger && store.root.contains(addTrigger)) {
|
||||
event.preventDefault();
|
||||
const targetKey = addTrigger.dataset?.nip71Add || "";
|
||||
const entry = this.addRepeaterEntry(key, targetKey);
|
||||
if (entry) {
|
||||
this.focusFirstField(entry, store.focusSelector);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const nestedAddTrigger = event.target?.closest?.("[data-nip71-nested-add]");
|
||||
if (nestedAddTrigger && store.root.contains(nestedAddTrigger)) {
|
||||
event.preventDefault();
|
||||
const nestedKey = nestedAddTrigger.dataset?.nip71NestedAdd || "";
|
||||
const container = nestedAddTrigger.closest(
|
||||
`[data-nip71-nested="${nestedKey}"]`
|
||||
);
|
||||
const entry = this.addNestedEntry(container, nestedKey);
|
||||
if (entry) {
|
||||
this.focusFirstField(
|
||||
entry,
|
||||
`[data-nip71-nested-field="${nestedKey}"]`
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const removeTrigger = event.target?.closest?.("[data-nip71-remove]");
|
||||
if (removeTrigger && store.root.contains(removeTrigger)) {
|
||||
event.preventDefault();
|
||||
const targetKey = removeTrigger.dataset?.nip71Remove || "";
|
||||
if (!targetKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (targetKey === "nested") {
|
||||
const nestedEntry = removeTrigger.closest("[data-nip71-nested-entry]");
|
||||
this.removeNestedEntry(nestedEntry);
|
||||
return;
|
||||
}
|
||||
|
||||
this.removeRepeaterEntry(key, targetKey, removeTrigger);
|
||||
}
|
||||
};
|
||||
|
||||
store.root.addEventListener("click", handleClick);
|
||||
store.handlers.click = handleClick;
|
||||
}
|
||||
|
||||
resetSection(key) {
|
||||
const store = this.getSection(key);
|
||||
if (!store) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(store.kindInputs)) {
|
||||
store.kindInputs.forEach((input) => {
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
input.checked = Boolean(input.defaultChecked);
|
||||
});
|
||||
}
|
||||
|
||||
if (store.summaryInput) {
|
||||
store.summaryInput.value = "";
|
||||
}
|
||||
if (store.publishedAtInput) {
|
||||
store.publishedAtInput.value = "";
|
||||
}
|
||||
if (store.altInput) {
|
||||
store.altInput.value = "";
|
||||
}
|
||||
if (store.durationInput) {
|
||||
store.durationInput.value = "";
|
||||
}
|
||||
if (store.contentWarningInput) {
|
||||
store.contentWarningInput.value = "";
|
||||
}
|
||||
|
||||
if (store.repeaters) {
|
||||
Object.entries(store.repeaters).forEach(([repeaterKey, repeater]) => {
|
||||
if (!repeater?.list) {
|
||||
return;
|
||||
}
|
||||
const entries = Array.from(
|
||||
repeater.list.querySelectorAll(
|
||||
`[data-nip71-entry="${repeaterKey}"]`
|
||||
)
|
||||
);
|
||||
entries.forEach((entry) => {
|
||||
if (entry.dataset?.nip71Primary === "true") {
|
||||
this.resetEntry(entry);
|
||||
} else {
|
||||
entry.remove();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
hydrateSection(key, metadata) {
|
||||
const store = this.getSection(key);
|
||||
if (!store) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.resetSection(key);
|
||||
|
||||
if (!metadata || typeof metadata !== "object") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(store.kindInputs)) {
|
||||
const targetValue = metadata.kind != null ? `${metadata.kind}`.trim() : "";
|
||||
if (targetValue) {
|
||||
store.kindInputs.forEach((input) => {
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
input.checked = `${input.value}` === targetValue;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (store.summaryInput) {
|
||||
store.summaryInput.value = this.toInputValue(metadata.summary);
|
||||
}
|
||||
if (store.publishedAtInput) {
|
||||
const value =
|
||||
metadata.publishedAt ?? metadata.published_at ?? metadata["published-at"];
|
||||
store.publishedAtInput.value = this.toInputValue(value);
|
||||
}
|
||||
if (store.altInput) {
|
||||
store.altInput.value = this.toInputValue(metadata.alt);
|
||||
}
|
||||
if (store.durationInput) {
|
||||
const duration = this.normalizeNumber(metadata.duration);
|
||||
store.durationInput.value = duration != null ? `${duration}` : "";
|
||||
}
|
||||
if (store.contentWarningInput) {
|
||||
const value =
|
||||
metadata.contentWarning ??
|
||||
metadata["content-warning"] ??
|
||||
metadata.content_warning;
|
||||
store.contentWarningInput.value = this.toInputValue(value);
|
||||
}
|
||||
|
||||
this.hydrateRepeater(key, "imeta", metadata.imeta, (entry, value) => {
|
||||
if (!value || typeof value !== "object") {
|
||||
return;
|
||||
}
|
||||
this.setFieldValue(entry, "m", value.m);
|
||||
this.setFieldValue(entry, "dim", value.dim);
|
||||
this.setFieldValue(entry, "url", value.url);
|
||||
this.setFieldValue(entry, "x", value.x);
|
||||
this.hydrateNested(entry, "image", value.image);
|
||||
this.hydrateNested(entry, "fallback", value.fallback);
|
||||
this.hydrateNested(entry, "service", value.service);
|
||||
});
|
||||
|
||||
this.hydrateRepeater(
|
||||
key,
|
||||
"text-track",
|
||||
metadata.textTracks ?? metadata["text-track"] ?? metadata.text_track,
|
||||
(entry, value) => {
|
||||
if (!value || typeof value !== "object") {
|
||||
return;
|
||||
}
|
||||
this.setFieldValue(entry, "url", value.url);
|
||||
this.setFieldValue(entry, "type", value.type);
|
||||
this.setFieldValue(entry, "language", value.language);
|
||||
}
|
||||
);
|
||||
|
||||
this.hydrateRepeater(key, "segment", metadata.segments, (entry, value) => {
|
||||
if (!value || typeof value !== "object") {
|
||||
return;
|
||||
}
|
||||
this.setFieldValue(entry, "start", value.start);
|
||||
this.setFieldValue(entry, "end", value.end);
|
||||
this.setFieldValue(entry, "title", value.title);
|
||||
this.setFieldValue(entry, "thumbnail", value.thumbnail);
|
||||
});
|
||||
|
||||
this.hydrateRepeater(key, "t", metadata.hashtags ?? metadata.t, (entry, value) => {
|
||||
this.setFieldValue(entry, "value", value);
|
||||
});
|
||||
|
||||
this.hydrateRepeater(
|
||||
key,
|
||||
"p",
|
||||
metadata.participants ?? metadata.p,
|
||||
(entry, value) => {
|
||||
if (!value || typeof value !== "object") {
|
||||
return;
|
||||
}
|
||||
this.setFieldValue(entry, "pubkey", value.pubkey ?? value["pubkey"]);
|
||||
this.setFieldValue(entry, "relay", value.relay ?? value["relay"]);
|
||||
}
|
||||
);
|
||||
|
||||
this.hydrateRepeater(
|
||||
key,
|
||||
"r",
|
||||
metadata.references ?? metadata.r,
|
||||
(entry, value) => {
|
||||
this.setFieldValue(entry, "url", value);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
collectSection(key) {
|
||||
const store = this.getSection(key);
|
||||
if (!store) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const kindInput = Array.isArray(store.kindInputs)
|
||||
? store.kindInputs.find((input) => input?.checked)
|
||||
: null;
|
||||
let kind = null;
|
||||
if (kindInput?.value != null) {
|
||||
const parsed = Number(kindInput.value);
|
||||
kind = Number.isFinite(parsed) ? parsed : String(kindInput.value).trim();
|
||||
}
|
||||
|
||||
const summary = this.getTrimmedValue(store.summaryInput);
|
||||
const publishedAt = this.getTrimmedValue(store.publishedAtInput);
|
||||
const alt = this.getTrimmedValue(store.altInput);
|
||||
|
||||
let duration = null;
|
||||
if (store.durationInput) {
|
||||
const rawValue = store.durationInput.value;
|
||||
if (rawValue !== "" && rawValue != null) {
|
||||
const parsed = Number(rawValue);
|
||||
if (Number.isFinite(parsed)) {
|
||||
duration = parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const contentWarning = this.getTrimmedValue(store.contentWarningInput);
|
||||
|
||||
const imeta = this.collectRepeaterValues(key, "imeta", (entry) => {
|
||||
const variant = {
|
||||
m: this.getFieldValue(entry, "m"),
|
||||
dim: this.getFieldValue(entry, "dim"),
|
||||
url: this.getFieldValue(entry, "url"),
|
||||
x: this.getFieldValue(entry, "x"),
|
||||
image: this.collectNestedValues(entry, "image"),
|
||||
fallback: this.collectNestedValues(entry, "fallback"),
|
||||
service: this.collectNestedValues(entry, "service"),
|
||||
};
|
||||
|
||||
const hasContent =
|
||||
variant.m ||
|
||||
variant.dim ||
|
||||
variant.url ||
|
||||
variant.x ||
|
||||
variant.image.length > 0 ||
|
||||
variant.fallback.length > 0 ||
|
||||
variant.service.length > 0;
|
||||
|
||||
return hasContent ? variant : null;
|
||||
});
|
||||
|
||||
const textTracks = this.collectRepeaterValues(
|
||||
key,
|
||||
"text-track",
|
||||
(entry) => {
|
||||
const track = {
|
||||
url: this.getFieldValue(entry, "url"),
|
||||
type: this.getFieldValue(entry, "type"),
|
||||
language: this.getFieldValue(entry, "language"),
|
||||
};
|
||||
|
||||
const hasContent = track.url || track.type || track.language;
|
||||
return hasContent ? track : null;
|
||||
}
|
||||
);
|
||||
|
||||
const segments = this.collectRepeaterValues(key, "segment", (entry) => {
|
||||
const segment = {
|
||||
start: this.getFieldValue(entry, "start"),
|
||||
end: this.getFieldValue(entry, "end"),
|
||||
title: this.getFieldValue(entry, "title"),
|
||||
thumbnail: this.getFieldValue(entry, "thumbnail"),
|
||||
};
|
||||
|
||||
const hasContent =
|
||||
segment.start || segment.end || segment.title || segment.thumbnail;
|
||||
return hasContent ? segment : null;
|
||||
});
|
||||
|
||||
const hashtags = this.collectRepeaterValues(key, "t", (entry) => {
|
||||
const value = this.getFieldValue(entry, "value");
|
||||
return value || null;
|
||||
});
|
||||
|
||||
const participants = this.collectRepeaterValues(key, "p", (entry) => {
|
||||
const participant = {
|
||||
pubkey: this.getFieldValue(entry, "pubkey"),
|
||||
relay: this.getFieldValue(entry, "relay"),
|
||||
};
|
||||
|
||||
const hasContent = participant.pubkey || participant.relay;
|
||||
return hasContent ? participant : null;
|
||||
});
|
||||
|
||||
const references = this.collectRepeaterValues(key, "r", (entry) => {
|
||||
const url = this.getFieldValue(entry, "url");
|
||||
return url || null;
|
||||
});
|
||||
|
||||
return {
|
||||
kind,
|
||||
summary,
|
||||
publishedAt,
|
||||
alt,
|
||||
duration,
|
||||
contentWarning,
|
||||
imeta,
|
||||
textTracks,
|
||||
segments,
|
||||
hashtags,
|
||||
participants,
|
||||
references,
|
||||
};
|
||||
}
|
||||
|
||||
collectRepeaterValues(sectionKey, repeaterKey, mapFn) {
|
||||
const store = this.getSection(sectionKey);
|
||||
const repeater = store?.repeaters?.[repeaterKey];
|
||||
if (!repeater?.list) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const entries = Array.from(
|
||||
repeater.list.querySelectorAll(`[data-nip71-entry="${repeaterKey}"]`)
|
||||
);
|
||||
|
||||
const results = [];
|
||||
entries.forEach((entry) => {
|
||||
const value = typeof mapFn === "function" ? mapFn(entry) : null;
|
||||
if (value == null) {
|
||||
return;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
if (value.trim()) {
|
||||
results.push(value.trim());
|
||||
}
|
||||
return;
|
||||
}
|
||||
results.push(value);
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
collectNestedValues(entry, nestedKey) {
|
||||
if (!entry || !nestedKey) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const container = entry.querySelector(
|
||||
`[data-nip71-nested="${nestedKey}"]`
|
||||
);
|
||||
if (!container) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const fields = Array.from(
|
||||
container.querySelectorAll(`[data-nip71-nested-field="${nestedKey}"]`)
|
||||
);
|
||||
|
||||
return fields
|
||||
.map((field) => this.getTrimmedValue(field))
|
||||
.filter((value) => Boolean(value));
|
||||
}
|
||||
|
||||
addRepeaterEntry(sectionKey, repeaterKey) {
|
||||
if (!repeaterKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const store = this.getSection(sectionKey);
|
||||
if (!store?.repeaters || !store.repeaters[repeaterKey]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { list, template } = store.repeaters[repeaterKey];
|
||||
if (!list || !template) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fragment = template.content
|
||||
? template.content.cloneNode(true)
|
||||
: template.cloneNode(true);
|
||||
|
||||
const entry =
|
||||
fragment.querySelector?.(`[data-nip71-entry="${repeaterKey}"]`) ||
|
||||
fragment.firstElementChild ||
|
||||
null;
|
||||
|
||||
list.appendChild(fragment);
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
removeRepeaterEntry(sectionKey, repeaterKey, trigger) {
|
||||
if (!repeaterKey || !trigger) {
|
||||
return;
|
||||
}
|
||||
|
||||
const store = this.getSection(sectionKey);
|
||||
if (!store?.repeaters?.[repeaterKey]?.list) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = trigger.closest(`[data-nip71-entry="${repeaterKey}"]`);
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.dataset?.nip71Primary === "true") {
|
||||
this.resetEntry(entry);
|
||||
return;
|
||||
}
|
||||
|
||||
entry.remove();
|
||||
}
|
||||
|
||||
addNestedEntry(container, nestedKey) {
|
||||
if (!container || !nestedKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const list = container.querySelector(
|
||||
`[data-nip71-nested-list="${nestedKey}"]`
|
||||
);
|
||||
const template = container.querySelector(
|
||||
`[data-nip71-nested-template="${nestedKey}"]`
|
||||
);
|
||||
|
||||
if (!list || !template) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fragment = template.content
|
||||
? template.content.cloneNode(true)
|
||||
: template.cloneNode(true);
|
||||
|
||||
const entry =
|
||||
fragment.querySelector?.(`[data-nip71-nested-entry="${nestedKey}"]`) ||
|
||||
fragment.firstElementChild ||
|
||||
null;
|
||||
|
||||
list.appendChild(fragment);
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
removeNestedEntry(entry) {
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
entry.remove();
|
||||
}
|
||||
|
||||
resetEntry(entry) {
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fields = entry.querySelectorAll("[data-nip71-field]");
|
||||
fields.forEach((field) => {
|
||||
if (field) {
|
||||
field.value = "";
|
||||
}
|
||||
});
|
||||
|
||||
const nestedContainers = entry.querySelectorAll("[data-nip71-nested]");
|
||||
nestedContainers.forEach((container) => {
|
||||
if (!container?.dataset?.nip71Nested) {
|
||||
return;
|
||||
}
|
||||
const list = container.querySelector(
|
||||
`[data-nip71-nested-list="${container.dataset.nip71Nested}"]`
|
||||
);
|
||||
if (list) {
|
||||
list.innerHTML = "";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
hydrateRepeater(sectionKey, repeaterKey, rawValues, applyValue) {
|
||||
const values = Array.isArray(rawValues) ? rawValues : [];
|
||||
if (!values.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const store = this.getSection(sectionKey);
|
||||
const repeater = store?.repeaters?.[repeaterKey];
|
||||
if (!repeater?.list) {
|
||||
return;
|
||||
}
|
||||
|
||||
let entries = Array.from(
|
||||
repeater.list.querySelectorAll(`[data-nip71-entry="${repeaterKey}"]`)
|
||||
);
|
||||
|
||||
values.forEach((value, index) => {
|
||||
let entry = entries[index];
|
||||
if (!entry) {
|
||||
entry = this.addRepeaterEntry(sectionKey, repeaterKey);
|
||||
if (entry) {
|
||||
entries.push(entry);
|
||||
}
|
||||
}
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
if (typeof applyValue === "function") {
|
||||
applyValue(entry, value, index);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
hydrateNested(entry, nestedKey, rawValues) {
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
|
||||
const values = Array.isArray(rawValues)
|
||||
? rawValues.filter((value) => this.toInputValue(value))
|
||||
: [];
|
||||
if (!values.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const container = entry.querySelector(`[data-nip71-nested="${nestedKey}"]`);
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
values.forEach((value) => {
|
||||
const nestedEntry = this.addNestedEntry(container, nestedKey);
|
||||
if (!nestedEntry) {
|
||||
return;
|
||||
}
|
||||
const field = nestedEntry.querySelector(
|
||||
`[data-nip71-nested-field="${nestedKey}"]`
|
||||
);
|
||||
if (field) {
|
||||
field.value = this.toInputValue(value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setFieldValue(entry, field, value) {
|
||||
if (!entry || !field) {
|
||||
return;
|
||||
}
|
||||
const element = entry.querySelector(`[data-nip71-field="${field}"]`);
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
element.value = this.toInputValue(value);
|
||||
}
|
||||
|
||||
focusFirstField(container, selector) {
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
const targetSelector = selector || this.defaultFocusSelector;
|
||||
const target = container.querySelector(targetSelector);
|
||||
if (target?.focus) {
|
||||
target.focus();
|
||||
}
|
||||
}
|
||||
|
||||
getFieldValue(entry, field) {
|
||||
if (!entry || !field) {
|
||||
return "";
|
||||
}
|
||||
const element = entry.querySelector(`[data-nip71-field="${field}"]`);
|
||||
return this.getTrimmedValue(element);
|
||||
}
|
||||
|
||||
getTrimmedValue(element) {
|
||||
if (!element) {
|
||||
return "";
|
||||
}
|
||||
const { value } = element;
|
||||
if (typeof value === "number") {
|
||||
return Number.isFinite(value) ? String(value).trim() : "";
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value.trim();
|
||||
}
|
||||
return String(value ?? "").trim();
|
||||
}
|
||||
|
||||
toInputValue(value) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
if (typeof value === "number") {
|
||||
return Number.isFinite(value) ? `${value}` : "";
|
||||
}
|
||||
if (typeof value === "boolean") {
|
||||
return value ? "true" : "false";
|
||||
}
|
||||
return `${value ?? ""}`;
|
||||
}
|
||||
|
||||
normalizeNumber(value) {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
const parsed = Number(value.trim());
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
cacheSection(section) {
|
||||
const repeaters = {};
|
||||
REPEATER_KEYS.forEach((key) => {
|
||||
repeaters[key] = this.cacheRepeater(section, key);
|
||||
});
|
||||
|
||||
return {
|
||||
root: section,
|
||||
focusSelector: this.defaultFocusSelector,
|
||||
kindInputs: Array.from(section.querySelectorAll('[data-nip71-input="kind"]')),
|
||||
publishedAtInput:
|
||||
section.querySelector('[data-nip71-input="published_at"]') || null,
|
||||
altInput: section.querySelector('[data-nip71-input="alt"]') || null,
|
||||
durationInput:
|
||||
section.querySelector('[data-nip71-input="duration"]') || null,
|
||||
contentWarningInput:
|
||||
section.querySelector('[data-nip71-input="content-warning"]') || null,
|
||||
summaryInput: section.querySelector('[data-nip71-input="summary"]') || null,
|
||||
repeaters,
|
||||
handlers: {},
|
||||
};
|
||||
}
|
||||
|
||||
cacheRepeater(section, key) {
|
||||
if (!section || !key) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const repeaterRoot = section.querySelector(
|
||||
`[data-nip71-repeater="${key}"]`
|
||||
);
|
||||
if (!repeaterRoot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
root: repeaterRoot,
|
||||
list: repeaterRoot.querySelector(`[data-nip71-list="${key}"]`) || null,
|
||||
template:
|
||||
repeaterRoot.querySelector(`[data-nip71-template="${key}"]`) || null,
|
||||
};
|
||||
}
|
||||
|
||||
getSection(key) {
|
||||
if (!key) {
|
||||
return null;
|
||||
}
|
||||
return this.sections.get(key) || null;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user