Standardize metadata popovers
This commit is contained in:
parent
9f7b408bc7
commit
9a09dde557
10 changed files with 1134 additions and 332 deletions
101
src/lib/components/admin/AlbumMetadataPopover.svelte
Normal file
101
src/lib/components/admin/AlbumMetadataPopover.svelte
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
<script lang="ts">
|
||||
import GenericMetadataPopover, { type MetadataConfig } from './GenericMetadataPopover.svelte'
|
||||
|
||||
type Props = {
|
||||
album: any
|
||||
triggerElement: HTMLElement
|
||||
onUpdate: (key: string, value: any) => void
|
||||
onDelete: () => void
|
||||
}
|
||||
|
||||
let {
|
||||
album = $bindable(),
|
||||
triggerElement,
|
||||
onUpdate,
|
||||
onDelete
|
||||
}: Props = $props()
|
||||
|
||||
// Convert album date to YYYY-MM-DD format for date input
|
||||
const albumDate = $derived(album.date ? new Date(album.date).toISOString().split('T')[0] : '')
|
||||
|
||||
// Handle date changes - convert back to ISO string
|
||||
function handleDateChange(key: string, value: string) {
|
||||
if (key === 'date') {
|
||||
const isoDate = value ? new Date(value).toISOString() : null
|
||||
onUpdate(key, isoDate)
|
||||
} else {
|
||||
onUpdate(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
const config: MetadataConfig = {
|
||||
title: 'Album Settings',
|
||||
fields: [
|
||||
{
|
||||
type: 'input',
|
||||
key: 'slug',
|
||||
label: 'Slug',
|
||||
placeholder: 'album-url-slug',
|
||||
helpText: 'Used in the album URL.'
|
||||
},
|
||||
{
|
||||
type: 'date',
|
||||
key: 'date',
|
||||
label: 'Date',
|
||||
helpText: 'When was this album created or photos taken?'
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
key: 'location',
|
||||
label: 'Location',
|
||||
placeholder: 'Location where photos were taken'
|
||||
},
|
||||
{
|
||||
type: 'section',
|
||||
key: 'display-options',
|
||||
label: 'Display Options'
|
||||
},
|
||||
{
|
||||
type: 'toggle',
|
||||
key: 'isPhotography',
|
||||
label: 'Show in Photos',
|
||||
helpText: 'Show this album in the photography experience'
|
||||
},
|
||||
{
|
||||
type: 'toggle',
|
||||
key: 'showInUniverse',
|
||||
label: 'Show in Universe',
|
||||
helpText: 'Display this album in the Universe feed'
|
||||
},
|
||||
{
|
||||
type: 'metadata',
|
||||
key: 'metadata'
|
||||
}
|
||||
],
|
||||
deleteButton: {
|
||||
label: 'Delete Album',
|
||||
action: onDelete
|
||||
}
|
||||
}
|
||||
|
||||
// Create a reactive data object that includes the formatted date
|
||||
let popoverData = $state({
|
||||
...album,
|
||||
date: albumDate
|
||||
})
|
||||
|
||||
// Sync changes back to album
|
||||
$effect(() => {
|
||||
popoverData = {
|
||||
...album,
|
||||
date: albumDate
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<GenericMetadataPopover
|
||||
{config}
|
||||
bind:data={popoverData}
|
||||
{triggerElement}
|
||||
onUpdate={handleDateChange}
|
||||
/>
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
import type { HTMLButtonAttributes } from 'svelte/elements'
|
||||
|
||||
interface Props extends HTMLButtonAttributes {
|
||||
variant?: 'primary' | 'secondary' | 'danger' | 'ghost' | 'text' | 'overlay'
|
||||
variant?: 'primary' | 'secondary' | 'danger' | 'ghost' | 'text' | 'overlay' | 'danger-text'
|
||||
buttonSize?: 'small' | 'medium' | 'large' | 'icon'
|
||||
iconOnly?: boolean
|
||||
iconPosition?: 'left' | 'right'
|
||||
|
|
@ -343,6 +343,23 @@
|
|||
}
|
||||
}
|
||||
|
||||
.btn-danger-text {
|
||||
background: none;
|
||||
color: #dc2626;
|
||||
padding: $unit;
|
||||
font-weight: 600;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: $grey-90;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
background-color: $grey-80;
|
||||
color: #dc2626;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-overlay {
|
||||
background-color: white;
|
||||
color: $grey-20;
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@
|
|||
import SmartImage from '../SmartImage.svelte'
|
||||
import MediaLibraryModal from './MediaLibraryModal.svelte'
|
||||
import { authenticatedFetch } from '$lib/admin-auth'
|
||||
import RefreshIcon from '$icons/refresh.svg?component'
|
||||
|
||||
interface Props {
|
||||
label: string
|
||||
|
|
@ -126,10 +125,10 @@
|
|||
try {
|
||||
// Initialize progress tracking
|
||||
const progressKeys = filesToUpload.map((file, index) => `${file.name}-${index}`)
|
||||
uploadProgress = Object.fromEntries(progressKeys.map(key => [key, 0]))
|
||||
uploadProgress = Object.fromEntries(progressKeys.map((key) => [key, 0]))
|
||||
|
||||
// Simulate progress for user feedback
|
||||
const progressIntervals = progressKeys.map(key => {
|
||||
const progressIntervals = progressKeys.map((key) => {
|
||||
return setInterval(() => {
|
||||
if (uploadProgress[key] < 90) {
|
||||
uploadProgress[key] += Math.random() * 10
|
||||
|
|
@ -141,10 +140,10 @@
|
|||
const uploadedMedia = await uploadFiles(filesToUpload)
|
||||
|
||||
// Clear progress intervals
|
||||
progressIntervals.forEach(interval => clearInterval(interval))
|
||||
progressIntervals.forEach((interval) => clearInterval(interval))
|
||||
|
||||
// Complete progress
|
||||
progressKeys.forEach(key => {
|
||||
progressKeys.forEach((key) => {
|
||||
uploadProgress[key] = 100
|
||||
})
|
||||
uploadProgress = { ...uploadProgress }
|
||||
|
|
@ -158,7 +157,6 @@
|
|||
isUploading = false
|
||||
uploadProgress = {}
|
||||
}, 500)
|
||||
|
||||
} catch (err) {
|
||||
isUploading = false
|
||||
uploadProgress = {}
|
||||
|
|
@ -241,9 +239,13 @@
|
|||
if (response.ok) {
|
||||
const updatedData = await response.json()
|
||||
if (value) {
|
||||
const index = value.findIndex(v => (v.mediaId || v.id) === mediaId)
|
||||
const index = value.findIndex((v) => (v.mediaId || v.id) === mediaId)
|
||||
if (index !== -1) {
|
||||
value[index] = { ...value[index], altText: updatedData.altText, updatedAt: updatedData.updatedAt }
|
||||
value[index] = {
|
||||
...value[index],
|
||||
altText: updatedData.altText,
|
||||
updatedAt: updatedData.updatedAt
|
||||
}
|
||||
value = [...value]
|
||||
}
|
||||
}
|
||||
|
|
@ -314,8 +316,8 @@
|
|||
|
||||
// Add selected media to existing gallery (avoid duplicates)
|
||||
// Check both id and mediaId to handle different object types
|
||||
const currentIds = value?.map(m => m.mediaId || m.id) || []
|
||||
const newMedia = mediaArray.filter(media => !currentIds.includes(media.id))
|
||||
const currentIds = value?.map((m) => m.mediaId || m.id) || []
|
||||
const newMedia = mediaArray.filter((media) => !currentIds.includes(media.id))
|
||||
|
||||
if (newMedia.length > 0) {
|
||||
const updatedGallery = [...(value || []), ...newMedia]
|
||||
|
|
@ -331,18 +333,6 @@
|
|||
</script>
|
||||
|
||||
<div class="gallery-uploader">
|
||||
<!-- Label -->
|
||||
<label class="uploader-label">
|
||||
{label}
|
||||
{#if required}
|
||||
<span class="required">*</span>
|
||||
{/if}
|
||||
</label>
|
||||
|
||||
{#if helpText}
|
||||
<p class="help-text">{helpText}</p>
|
||||
{/if}
|
||||
|
||||
<!-- Upload Area -->
|
||||
{#if !hasImages || (hasImages && canAddMore)}
|
||||
<div
|
||||
|
|
@ -398,12 +388,53 @@
|
|||
{:else}
|
||||
<!-- Upload Prompt -->
|
||||
<div class="upload-prompt">
|
||||
<svg class="upload-icon" width="48" height="48" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14 2H6A2 2 0 0 0 4 4V20A2 2 0 0 0 6 22H18A2 2 0 0 0 20 20V8L14 2Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<polyline points="14,2 14,8 20,8" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<line x1="16" y1="13" x2="8" y2="13" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="16" y1="17" x2="8" y2="17" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
<polyline points="10,9 9,9 8,9" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<svg
|
||||
class="upload-icon"
|
||||
width="48"
|
||||
height="48"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M14 2H6A2 2 0 0 0 4 4V20A2 2 0 0 0 6 22H18A2 2 0 0 0 20 20V8L14 2Z"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<polyline
|
||||
points="14,2 14,8 20,8"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<line
|
||||
x1="16"
|
||||
y1="13"
|
||||
x2="8"
|
||||
y2="13"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<line
|
||||
x1="16"
|
||||
y1="17"
|
||||
x2="8"
|
||||
y2="17"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<polyline
|
||||
points="10,9 9,9 8,9"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
<p class="upload-main-text">{placeholder}</p>
|
||||
<p class="upload-sub-text">
|
||||
|
|
@ -428,9 +459,7 @@
|
|||
</Button>
|
||||
|
||||
{#if showBrowseLibrary}
|
||||
<Button variant="ghost" onclick={handleBrowseLibrary}>
|
||||
Browse Library
|
||||
</Button>
|
||||
<Button variant="ghost" onclick={handleBrowseLibrary}>Browse Library</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -452,13 +481,19 @@
|
|||
>
|
||||
<!-- Drag Handle -->
|
||||
<div class="drag-handle">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="9" cy="6" r="2" fill="currentColor"/>
|
||||
<circle cx="15" cy="6" r="2" fill="currentColor"/>
|
||||
<circle cx="9" cy="12" r="2" fill="currentColor"/>
|
||||
<circle cx="15" cy="12" r="2" fill="currentColor"/>
|
||||
<circle cx="9" cy="18" r="2" fill="currentColor"/>
|
||||
<circle cx="15" cy="18" r="2" fill="currentColor"/>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<circle cx="9" cy="6" r="2" fill="currentColor" />
|
||||
<circle cx="15" cy="6" r="2" fill="currentColor" />
|
||||
<circle cx="9" cy="12" r="2" fill="currentColor" />
|
||||
<circle cx="15" cy="12" r="2" fill="currentColor" />
|
||||
<circle cx="9" cy="18" r="2" fill="currentColor" />
|
||||
<circle cx="15" cy="18" r="2" fill="currentColor" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
|
|
@ -495,9 +530,33 @@
|
|||
type="button"
|
||||
aria-label="Remove image"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<line x1="18" y1="6" x2="6" y2="18" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<line x1="6" y1="6" x2="18" y2="18" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<line
|
||||
x1="18"
|
||||
y1="6"
|
||||
x2="6"
|
||||
y2="18"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<line
|
||||
x1="6"
|
||||
y1="6"
|
||||
x2="18"
|
||||
y2="18"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
|||
450
src/lib/components/admin/GenericMetadataPopover.svelte
Normal file
450
src/lib/components/admin/GenericMetadataPopover.svelte
Normal file
|
|
@ -0,0 +1,450 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import Input from './Input.svelte'
|
||||
import FormFieldWrapper from './FormFieldWrapper.svelte'
|
||||
import Button from './Button.svelte'
|
||||
|
||||
export interface MetadataField {
|
||||
type: 'input' | 'textarea' | 'date' | 'toggle' | 'tags' | 'metadata' | 'custom' | 'section'
|
||||
key: string
|
||||
label?: string
|
||||
placeholder?: string
|
||||
rows?: number
|
||||
helpText?: string
|
||||
component?: any // For custom components
|
||||
props?: any // Additional props for custom components
|
||||
}
|
||||
|
||||
export interface MetadataConfig {
|
||||
title: string
|
||||
fields: MetadataField[]
|
||||
deleteButton?: {
|
||||
label: string
|
||||
action: () => void
|
||||
}
|
||||
}
|
||||
|
||||
type Props = {
|
||||
config: MetadataConfig
|
||||
data: any
|
||||
triggerElement: HTMLElement
|
||||
onUpdate?: (key: string, value: any) => void
|
||||
onAddTag?: () => void
|
||||
onRemoveTag?: (tag: string) => void
|
||||
}
|
||||
|
||||
let {
|
||||
config,
|
||||
data = $bindable(),
|
||||
triggerElement,
|
||||
onUpdate = () => {},
|
||||
onAddTag = () => {},
|
||||
onRemoveTag = () => {}
|
||||
}: Props = $props()
|
||||
|
||||
let popoverElement: HTMLDivElement
|
||||
let portalTarget: HTMLElement
|
||||
|
||||
function updatePosition() {
|
||||
if (!popoverElement || !triggerElement) return
|
||||
|
||||
const triggerRect = triggerElement.getBoundingClientRect()
|
||||
const popoverRect = popoverElement.getBoundingClientRect()
|
||||
const viewportWidth = window.innerWidth
|
||||
const viewportHeight = window.innerHeight
|
||||
|
||||
// Find the AdminPage container to align with its right edge
|
||||
const adminPage =
|
||||
document.querySelector('.admin-page') || document.querySelector('[data-admin-page]')
|
||||
const adminPageRect = adminPage?.getBoundingClientRect()
|
||||
|
||||
// Position below the trigger button
|
||||
let top = triggerRect.bottom + 8
|
||||
|
||||
// Align closer to the right edge of AdminPage, with some padding
|
||||
let left: number
|
||||
if (adminPageRect) {
|
||||
// Position to align with AdminPage right edge minus padding
|
||||
left = adminPageRect.right - popoverRect.width - 24
|
||||
} else {
|
||||
// Fallback to viewport-based positioning
|
||||
left = triggerRect.right - popoverRect.width
|
||||
}
|
||||
|
||||
// Ensure we don't go off-screen horizontally
|
||||
if (left < 16) {
|
||||
left = 16
|
||||
} else if (left + popoverRect.width > viewportWidth - 16) {
|
||||
left = viewportWidth - popoverRect.width - 16
|
||||
}
|
||||
|
||||
// Check if popover would go off-screen vertically (both top and bottom)
|
||||
if (top + popoverRect.height > viewportHeight - 16) {
|
||||
// Try positioning above the trigger
|
||||
const topAbove = triggerRect.top - popoverRect.height - 8
|
||||
if (topAbove >= 16) {
|
||||
top = topAbove
|
||||
} else {
|
||||
// If neither above nor below works, position with maximum available space
|
||||
if (triggerRect.top > viewportHeight - triggerRect.bottom) {
|
||||
// More space above - position at top of viewport with margin
|
||||
top = 16
|
||||
} else {
|
||||
// More space below - position at bottom of viewport with margin
|
||||
top = viewportHeight - popoverRect.height - 16
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also check if positioning below would place us off the top (shouldn't happen but be safe)
|
||||
if (top < 16) {
|
||||
top = 16
|
||||
}
|
||||
|
||||
popoverElement.style.position = 'fixed'
|
||||
popoverElement.style.top = `${top}px`
|
||||
popoverElement.style.left = `${left}px`
|
||||
popoverElement.style.zIndex = '1000'
|
||||
}
|
||||
|
||||
function handleFieldUpdate(key: string, value: any) {
|
||||
data[key] = value
|
||||
onUpdate(key, value)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
// Create portal target
|
||||
portalTarget = document.createElement('div')
|
||||
portalTarget.style.position = 'absolute'
|
||||
portalTarget.style.top = '0'
|
||||
portalTarget.style.left = '0'
|
||||
portalTarget.style.pointerEvents = 'none'
|
||||
document.body.appendChild(portalTarget)
|
||||
|
||||
// Initial positioning
|
||||
updatePosition()
|
||||
|
||||
// Update position on scroll/resize
|
||||
const handleUpdate = () => updatePosition()
|
||||
window.addEventListener('scroll', handleUpdate, true)
|
||||
window.addEventListener('resize', handleUpdate)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('scroll', handleUpdate, true)
|
||||
window.removeEventListener('resize', handleUpdate)
|
||||
if (portalTarget) {
|
||||
document.body.removeChild(portalTarget)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if (popoverElement && portalTarget && triggerElement) {
|
||||
portalTarget.appendChild(popoverElement)
|
||||
portalTarget.style.pointerEvents = 'auto'
|
||||
updatePosition()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="metadata-popover" bind:this={popoverElement}>
|
||||
<div class="popover-content">
|
||||
<h3>{config.title}</h3>
|
||||
|
||||
{#each config.fields as field}
|
||||
{#if field.type === 'input'}
|
||||
<Input
|
||||
label={field.label}
|
||||
bind:value={data[field.key]}
|
||||
placeholder={field.placeholder}
|
||||
helpText={field.helpText}
|
||||
onchange={() => handleFieldUpdate(field.key, data[field.key])}
|
||||
/>
|
||||
{:else if field.type === 'textarea'}
|
||||
<Input
|
||||
type="textarea"
|
||||
label={field.label}
|
||||
bind:value={data[field.key]}
|
||||
rows={field.rows || 3}
|
||||
placeholder={field.placeholder}
|
||||
helpText={field.helpText}
|
||||
onchange={() => handleFieldUpdate(field.key, data[field.key])}
|
||||
/>
|
||||
{:else if field.type === 'date'}
|
||||
<Input
|
||||
type="date"
|
||||
label={field.label}
|
||||
bind:value={data[field.key]}
|
||||
helpText={field.helpText}
|
||||
onchange={() => handleFieldUpdate(field.key, data[field.key])}
|
||||
/>
|
||||
{:else if field.type === 'toggle'}
|
||||
<div class="toggle-wrapper">
|
||||
<label class="toggle-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={data[field.key]}
|
||||
class="toggle-input"
|
||||
onchange={() => handleFieldUpdate(field.key, data[field.key])}
|
||||
/>
|
||||
<span class="toggle-slider"></span>
|
||||
<div class="toggle-content">
|
||||
<span class="toggle-title">{field.label}</span>
|
||||
{#if field.helpText}
|
||||
<span class="toggle-description">{field.helpText}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
{:else if field.type === 'tags'}
|
||||
<div class="tags-section">
|
||||
<Input
|
||||
label={field.label}
|
||||
bind:value={data.tagInput}
|
||||
onkeydown={(e) => e.key === 'Enter' && (e.preventDefault(), onAddTag())}
|
||||
placeholder={field.placeholder || "Add tags..."}
|
||||
/>
|
||||
<button type="button" onclick={onAddTag} class="add-tag-btn">Add</button>
|
||||
|
||||
{#if data[field.key] && data[field.key].length > 0}
|
||||
<div class="tags">
|
||||
{#each data[field.key] as tag}
|
||||
<span class="tag">
|
||||
{tag}
|
||||
<button onclick={() => onRemoveTag(tag)}>×</button>
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if field.type === 'metadata'}
|
||||
<div class="metadata">
|
||||
<p>Created: {new Date(data.createdAt).toLocaleString()}</p>
|
||||
<p>Updated: {new Date(data.updatedAt).toLocaleString()}</p>
|
||||
{#if data.publishedAt}
|
||||
<p>Published: {new Date(data.publishedAt).toLocaleString()}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if field.type === 'section'}
|
||||
<div class="section-header">
|
||||
<h4>{field.label}</h4>
|
||||
</div>
|
||||
{:else if field.type === 'custom' && field.component}
|
||||
<svelte:component this={field.component} {...field.props} bind:data />
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if config.deleteButton}
|
||||
<div class="popover-footer">
|
||||
<Button variant="danger-text" pill={false} onclick={config.deleteButton.action}>
|
||||
<svg slot="icon" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path
|
||||
d="M4 4L12 12M4 12L12 4"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
{config.deleteButton.label}
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@import '$styles/variables.scss';
|
||||
|
||||
.metadata-popover {
|
||||
background: white;
|
||||
border: 1px solid $grey-80;
|
||||
border-radius: $card-corner-radius;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||
min-width: 420px;
|
||||
max-width: 480px;
|
||||
max-height: calc(100vh - #{$unit-2x * 2});
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
pointer-events: auto;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.popover-content {
|
||||
padding: $unit-3x;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-3x;
|
||||
|
||||
h3 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: $grey-10;
|
||||
}
|
||||
}
|
||||
|
||||
.popover-footer {
|
||||
padding: $unit-3x;
|
||||
border-top: 1px solid $grey-90;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
margin: $unit-3x 0 $unit 0;
|
||||
|
||||
&:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
h4 {
|
||||
display: block;
|
||||
margin-bottom: $unit;
|
||||
font-weight: 500;
|
||||
color: $grey-20;
|
||||
font-size: 0.925rem;
|
||||
}
|
||||
}
|
||||
|
||||
.tags-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit;
|
||||
}
|
||||
|
||||
.add-tag-btn {
|
||||
align-self: flex-start;
|
||||
margin-top: $unit-half;
|
||||
padding: $unit $unit-2x;
|
||||
background: $grey-10;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: $grey-20;
|
||||
}
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: $unit;
|
||||
margin-top: $unit;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px $unit-2x;
|
||||
background: $grey-80;
|
||||
border-radius: 20px;
|
||||
font-size: 0.75rem;
|
||||
|
||||
button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: $grey-40;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
|
||||
&:hover {
|
||||
color: $grey-10;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.metadata {
|
||||
font-size: 0.75rem;
|
||||
color: $grey-40;
|
||||
|
||||
p {
|
||||
margin: $unit-half 0;
|
||||
}
|
||||
}
|
||||
|
||||
.toggle-wrapper {
|
||||
.toggle-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $unit-3x;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.toggle-input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
|
||||
&:checked + .toggle-slider {
|
||||
background-color: $blue-60;
|
||||
|
||||
&::before {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
}
|
||||
|
||||
&:disabled + .toggle-slider {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.toggle-slider {
|
||||
position: relative;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
background-color: $grey-80;
|
||||
border-radius: 12px;
|
||||
transition: background-color 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background-color: white;
|
||||
border-radius: 50%;
|
||||
transition: transform 0.2s ease;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.toggle-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-half;
|
||||
|
||||
.toggle-title {
|
||||
font-weight: 500;
|
||||
color: $grey-10;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.toggle-description {
|
||||
font-size: 0.75rem;
|
||||
color: $grey-50;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@include breakpoint('phone') {
|
||||
.metadata-popover {
|
||||
min-width: 280px;
|
||||
max-width: calc(100vw - 2rem);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -64,9 +64,27 @@
|
|||
left = viewportWidth - popoverRect.width - 16
|
||||
}
|
||||
|
||||
// Adjust if would go off-screen vertically
|
||||
// Check if popover would go off-screen vertically (both top and bottom)
|
||||
if (top + popoverRect.height > viewportHeight - 16) {
|
||||
top = triggerRect.top - popoverRect.height - 8
|
||||
// Try positioning above the trigger
|
||||
const topAbove = triggerRect.top - popoverRect.height - 8
|
||||
if (topAbove >= 16) {
|
||||
top = topAbove
|
||||
} else {
|
||||
// If neither above nor below works, position with maximum available space
|
||||
if (triggerRect.top > viewportHeight - triggerRect.bottom) {
|
||||
// More space above - position at top of viewport with margin
|
||||
top = 16
|
||||
} else {
|
||||
// More space below - position at bottom of viewport with margin
|
||||
top = viewportHeight - popoverRect.height - 16
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also check if positioning below would place us off the top (shouldn't happen but be safe)
|
||||
if (top < 16) {
|
||||
top = 16
|
||||
}
|
||||
|
||||
popoverElement.style.position = 'fixed'
|
||||
|
|
|
|||
105
src/lib/components/admin/PostMetadataPopover.svelte
Normal file
105
src/lib/components/admin/PostMetadataPopover.svelte
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
<script lang="ts">
|
||||
import GenericMetadataPopover, { type MetadataConfig } from './GenericMetadataPopover.svelte'
|
||||
|
||||
type Props = {
|
||||
post: any
|
||||
postType: 'post' | 'essay'
|
||||
slug: string
|
||||
excerpt: string
|
||||
tags: string[]
|
||||
tagInput: string
|
||||
triggerElement: HTMLElement
|
||||
onAddTag: () => void
|
||||
onRemoveTag: (tag: string) => void
|
||||
onDelete: () => void
|
||||
}
|
||||
|
||||
let {
|
||||
post,
|
||||
postType,
|
||||
slug = $bindable(),
|
||||
excerpt = $bindable(),
|
||||
tags = $bindable(),
|
||||
tagInput = $bindable(),
|
||||
triggerElement,
|
||||
onAddTag,
|
||||
onRemoveTag,
|
||||
onDelete
|
||||
}: Props = $props()
|
||||
|
||||
function handleFieldUpdate(key: string, value: any) {
|
||||
if (key === 'slug') {
|
||||
slug = value
|
||||
} else if (key === 'excerpt') {
|
||||
excerpt = value
|
||||
} else if (key === 'tagInput') {
|
||||
tagInput = value
|
||||
}
|
||||
}
|
||||
|
||||
const config: MetadataConfig = {
|
||||
title: 'Post Settings',
|
||||
fields: [
|
||||
{
|
||||
type: 'input',
|
||||
key: 'slug',
|
||||
label: 'Slug',
|
||||
placeholder: 'post-slug'
|
||||
},
|
||||
...(postType === 'essay' ? [{
|
||||
type: 'textarea' as const,
|
||||
key: 'excerpt',
|
||||
label: 'Excerpt',
|
||||
rows: 3,
|
||||
placeholder: 'Brief description...'
|
||||
}] : []),
|
||||
{
|
||||
type: 'tags',
|
||||
key: 'tags',
|
||||
label: 'Tags',
|
||||
placeholder: 'Add tags...'
|
||||
},
|
||||
{
|
||||
type: 'metadata',
|
||||
key: 'metadata'
|
||||
}
|
||||
],
|
||||
deleteButton: {
|
||||
label: 'Delete Post',
|
||||
action: onDelete
|
||||
}
|
||||
}
|
||||
|
||||
// Create a reactive data object
|
||||
let popoverData = $state({
|
||||
slug,
|
||||
excerpt,
|
||||
tags,
|
||||
tagInput,
|
||||
createdAt: post.createdAt,
|
||||
updatedAt: post.updatedAt,
|
||||
publishedAt: post.publishedAt
|
||||
})
|
||||
|
||||
// Sync changes back
|
||||
$effect(() => {
|
||||
popoverData = {
|
||||
slug,
|
||||
excerpt,
|
||||
tags,
|
||||
tagInput,
|
||||
createdAt: post.createdAt,
|
||||
updatedAt: post.updatedAt,
|
||||
publishedAt: post.publishedAt
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<GenericMetadataPopover
|
||||
{config}
|
||||
bind:data={popoverData}
|
||||
{triggerElement}
|
||||
onUpdate={handleFieldUpdate}
|
||||
{onAddTag}
|
||||
{onRemoveTag}
|
||||
/>
|
||||
|
|
@ -7,11 +7,11 @@
|
|||
import Input from '$lib/components/admin/Input.svelte'
|
||||
import FormFieldWrapper from '$lib/components/admin/FormFieldWrapper.svelte'
|
||||
import LoadingSpinner from '$lib/components/admin/LoadingSpinner.svelte'
|
||||
import DeleteConfirmationModal from '$lib/components/admin/DeleteConfirmationModal.svelte'
|
||||
import MediaLibraryModal from '$lib/components/admin/MediaLibraryModal.svelte'
|
||||
import MediaDetailsModal from '$lib/components/admin/MediaDetailsModal.svelte'
|
||||
import GalleryUploader from '$lib/components/admin/GalleryUploader.svelte'
|
||||
import SaveActionsGroup from '$lib/components/admin/SaveActionsGroup.svelte'
|
||||
import AlbumMetadataPopover from '$lib/components/admin/AlbumMetadataPopover.svelte'
|
||||
|
||||
// Form state
|
||||
let album = $state<any>(null)
|
||||
|
|
@ -28,7 +28,6 @@
|
|||
let isLoading = $state(true)
|
||||
let isSaving = $state(false)
|
||||
let error = $state('')
|
||||
let isDeleteModalOpen = $state(false)
|
||||
|
||||
// Photo management state
|
||||
let isMediaLibraryOpen = $state(false)
|
||||
|
|
@ -39,6 +38,10 @@
|
|||
let isMediaDetailsOpen = $state(false)
|
||||
let selectedMedia = $state<any>(null)
|
||||
|
||||
// Metadata popover state
|
||||
let isMetadataOpen = $state(false)
|
||||
let metadataButtonElement: HTMLButtonElement
|
||||
|
||||
onMount(async () => {
|
||||
await loadAlbum()
|
||||
})
|
||||
|
|
@ -443,8 +446,8 @@
|
|||
try {
|
||||
if (newPhotos.length > 0) {
|
||||
// Check if these are new uploads (have File objects) or library selections (have media IDs)
|
||||
const uploadsToAdd = newPhotos.filter(photo => photo instanceof File || !photo.id)
|
||||
const libraryPhotosToAdd = newPhotos.filter(photo => photo.id && !(photo instanceof File))
|
||||
const uploadsToAdd = newPhotos.filter((photo) => photo instanceof File || !photo.id)
|
||||
const libraryPhotosToAdd = newPhotos.filter((photo) => photo.id && !(photo instanceof File))
|
||||
|
||||
// Handle new uploads
|
||||
if (uploadsToAdd.length > 0) {
|
||||
|
|
@ -487,7 +490,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
function generateSlug(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
|
|
@ -503,6 +505,34 @@
|
|||
})
|
||||
|
||||
const canSave = $derived(title.trim().length > 0 && slug.trim().length > 0)
|
||||
|
||||
// Metadata popover handlers
|
||||
function handleMetadataUpdate(key: string, value: any) {
|
||||
if (key === 'date') {
|
||||
date = value ? new Date(value).toISOString().split('T')[0] : ''
|
||||
} else {
|
||||
// Update the form state variable
|
||||
switch (key) {
|
||||
case 'slug':
|
||||
slug = value
|
||||
break
|
||||
case 'location':
|
||||
location = value
|
||||
break
|
||||
case 'isPhotography':
|
||||
isPhotography = value
|
||||
break
|
||||
case 'showInUniverse':
|
||||
showInUniverse = value
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleMetadataDelete() {
|
||||
isMetadataOpen = false
|
||||
handleDelete()
|
||||
}
|
||||
</script>
|
||||
|
||||
<AdminPage>
|
||||
|
|
@ -522,23 +552,34 @@
|
|||
</button>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<Button
|
||||
variant="ghost"
|
||||
buttonSize="large"
|
||||
onclick={() => (isDeleteModalOpen = true)}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<svg slot="icon" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path
|
||||
d="M6 3V2C6 1.44772 6.44772 1 7 1H9C9.55228 1 10 1.44772 10 2V3M13 4H3M5 7V12M8 7V12M11 7V12M4 4L4.5 13C4.55228 13.5523 4.99772 14 5.5 14H10.5C11.0023 14 11.4477 13.5523 11.5 13L12 4H4Z"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
<div class="metadata-popover-container">
|
||||
<button
|
||||
bind:this={metadataButtonElement}
|
||||
class="btn btn-text"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation()
|
||||
isMetadataOpen = !isMetadataOpen
|
||||
}}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 56 56" fill="none">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M 36.4023 19.3164 C 38.8398 19.3164 40.9257 17.7461 41.6992 15.5898 L 49.8085 15.5898 C 50.7695 15.5898 51.6133 14.7461 51.6133 13.6914 C 51.6133 12.6367 50.7695 11.8164 49.8085 11.8164 L 41.7226 11.8164 C 40.9257 9.6367 38.8398 8.0430 36.4023 8.0430 C 33.9648 8.0430 31.8789 9.6367 31.1054 11.8164 L 6.2851 11.8164 C 5.2304 11.8164 4.3867 12.6367 4.3867 13.6914 C 4.3867 14.7461 5.2304 15.5898 6.2851 15.5898 L 31.1054 15.5898 C 31.8789 17.7461 33.9648 19.3164 36.4023 19.3164 Z M 6.1913 26.1133 C 5.2304 26.1133 4.3867 26.9570 4.3867 28.0117 C 4.3867 29.0664 5.2304 29.8867 6.1913 29.8867 L 14.5586 29.8867 C 15.3320 32.0898 17.4179 33.6601 19.8554 33.6601 C 22.3164 33.6601 24.4023 32.0898 25.1757 29.8867 L 49.7149 29.8867 C 50.7695 29.8867 51.6133 29.0664 51.6133 28.0117 C 51.6133 26.9570 50.7695 26.1133 49.7149 26.1133 L 25.1757 26.1133 C 24.3789 23.9570 22.2929 22.3867 19.8554 22.3867 C 17.4413 22.3867 15.3554 23.9570 14.5586 26.1133 Z M 36.4023 47.9570 C 38.8398 47.9570 40.9257 46.3867 41.6992 44.2070 L 49.8085 44.2070 C 50.7695 44.2070 51.6133 43.3867 51.6133 42.3320 C 51.6133 41.2773 50.7695 40.4336 49.8085 40.4336 L 41.6992 40.4336 C 40.9257 38.2539 38.8398 36.7070 36.4023 36.7070 C 33.9648 36.7070 31.8789 38.2539 31.1054 40.4336 L 6.2851 40.4336 C 5.2304 40.4336 4.3867 41.2773 4.3867 42.3320 C 4.3867 43.3867 5.2304 44.2070 6.2851 44.2070 L 31.1054 44.2070 C 31.8789 46.3867 33.9648 47.9570 36.4023 47.9570 Z"
|
||||
/>
|
||||
</svg>
|
||||
Metadata
|
||||
</button>
|
||||
|
||||
{#if isMetadataOpen && metadataButtonElement && album}
|
||||
<AlbumMetadataPopover
|
||||
bind:album
|
||||
triggerElement={metadataButtonElement}
|
||||
onUpdate={handleMetadataUpdate}
|
||||
onDelete={handleMetadataDelete}
|
||||
/>
|
||||
</svg>
|
||||
Delete
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
<SaveActionsGroup
|
||||
{status}
|
||||
onSave={handleSave}
|
||||
|
|
@ -577,15 +618,6 @@
|
|||
fullWidth
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Slug"
|
||||
bind:value={slug}
|
||||
placeholder="album-url-slug"
|
||||
helpText="Used in the album URL."
|
||||
disabled={isSaving}
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<Input
|
||||
type="textarea"
|
||||
label="Description"
|
||||
|
|
@ -595,66 +627,6 @@
|
|||
disabled={isSaving}
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<div class="form-row">
|
||||
<Input
|
||||
type="date"
|
||||
label="Date"
|
||||
bind:value={date}
|
||||
helpText="When was this album created or photos taken?"
|
||||
disabled={isSaving}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Location"
|
||||
bind:value={location}
|
||||
placeholder="Location where photos were taken"
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<h2>Album Settings</h2>
|
||||
|
||||
<!-- Photography Toggle -->
|
||||
<FormFieldWrapper label="Album Type">
|
||||
<div class="photography-toggle">
|
||||
<label class="toggle-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={isPhotography}
|
||||
disabled={isSaving}
|
||||
class="toggle-input"
|
||||
/>
|
||||
<span class="toggle-slider"></span>
|
||||
<div class="toggle-content">
|
||||
<span class="toggle-title">Photography Album</span>
|
||||
<span class="toggle-description">Show this album in the photography experience</span
|
||||
>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</FormFieldWrapper>
|
||||
|
||||
<!-- Show in Universe Toggle -->
|
||||
<FormFieldWrapper label="Visibility">
|
||||
<div class="universe-toggle">
|
||||
<label class="toggle-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={showInUniverse}
|
||||
disabled={isSaving}
|
||||
class="toggle-input"
|
||||
/>
|
||||
<span class="toggle-slider"></span>
|
||||
<div class="toggle-content">
|
||||
<span class="toggle-title">Show in Universe</span>
|
||||
<span class="toggle-description">Display this album in the Universe feed</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</FormFieldWrapper>
|
||||
</div>
|
||||
|
||||
<!-- Photo Management -->
|
||||
|
|
@ -695,16 +667,6 @@
|
|||
{/if}
|
||||
</AdminPage>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<DeleteConfirmationModal
|
||||
bind:isOpen={isDeleteModalOpen}
|
||||
title="Delete Album"
|
||||
message="Are you sure you want to delete this album? This action cannot be undone."
|
||||
confirmText="Delete Album"
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => (isDeleteModalOpen = false)}
|
||||
/>
|
||||
|
||||
<!-- Media Library Modal -->
|
||||
<MediaLibraryModal
|
||||
bind:isOpen={isMediaLibraryOpen}
|
||||
|
|
@ -761,6 +723,49 @@
|
|||
background: $grey-90;
|
||||
color: $grey-10;
|
||||
}
|
||||
|
||||
&.metadata-btn {
|
||||
&:hover {
|
||||
background: $blue-60;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
padding: $unit $unit-2x;
|
||||
border: none;
|
||||
background: none;
|
||||
color: $grey-40;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $unit;
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: $grey-90;
|
||||
color: $grey-10;
|
||||
}
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: $unit-2x $unit-3x;
|
||||
border: none;
|
||||
border-radius: 50px;
|
||||
font-size: 0.925rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $unit;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
|
|
@ -1072,7 +1077,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
// Upload status styles
|
||||
.upload-status {
|
||||
background: white;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,11 @@
|
|||
import Button from '$lib/components/admin/Button.svelte'
|
||||
import Input from '$lib/components/admin/Input.svelte'
|
||||
import FormFieldWrapper from '$lib/components/admin/FormFieldWrapper.svelte'
|
||||
import PublishDropdown from '$lib/components/admin/PublishDropdown.svelte'
|
||||
import MediaLibraryModal from '$lib/components/admin/MediaLibraryModal.svelte'
|
||||
import MediaDetailsModal from '$lib/components/admin/MediaDetailsModal.svelte'
|
||||
import GalleryUploader from '$lib/components/admin/GalleryUploader.svelte'
|
||||
import SaveActionsGroup from '$lib/components/admin/SaveActionsGroup.svelte'
|
||||
import AlbumMetadataPopover from '$lib/components/admin/AlbumMetadataPopover.svelte'
|
||||
|
||||
// Form state
|
||||
let title = $state('')
|
||||
|
|
@ -20,6 +24,19 @@
|
|||
let isSaving = $state(false)
|
||||
let error = $state('')
|
||||
|
||||
// Photo management state
|
||||
let isMediaLibraryOpen = $state(false)
|
||||
let albumPhotos = $state<any[]>([])
|
||||
let isManagingPhotos = $state(false)
|
||||
|
||||
// Media details modal state
|
||||
let isMediaDetailsOpen = $state(false)
|
||||
let selectedMedia = $state<any>(null)
|
||||
|
||||
// Metadata popover state
|
||||
let isMetadataOpen = $state(false)
|
||||
let metadataButtonElement: HTMLButtonElement
|
||||
|
||||
// Auto-generate slug from title
|
||||
$effect(() => {
|
||||
if (title && !slug) {
|
||||
|
|
@ -96,6 +113,107 @@
|
|||
goto('/admin/albums')
|
||||
}
|
||||
|
||||
// Photo management functions (simplified for new album - no API calls yet)
|
||||
function handleMediaLibraryClose() {
|
||||
isMediaLibraryOpen = false
|
||||
}
|
||||
|
||||
function handlePhotoClick(photo: any) {
|
||||
// Convert album photo to media format for MediaDetailsModal
|
||||
selectedMedia = {
|
||||
id: photo.mediaId || photo.id,
|
||||
filename: photo.filename,
|
||||
originalName: photo.filename,
|
||||
mimeType: photo.mimeType || 'image/jpeg',
|
||||
size: photo.size || 0,
|
||||
url: photo.url,
|
||||
thumbnailUrl: photo.thumbnailUrl,
|
||||
width: photo.width,
|
||||
height: photo.height,
|
||||
altText: photo.altText || '',
|
||||
description: photo.description || '',
|
||||
isPhotography: photo.isPhotography || false,
|
||||
createdAt: photo.createdAt,
|
||||
updatedAt: photo.updatedAt
|
||||
}
|
||||
isMediaDetailsOpen = true
|
||||
}
|
||||
|
||||
function handleMediaDetailsClose() {
|
||||
isMediaDetailsOpen = false
|
||||
selectedMedia = null
|
||||
}
|
||||
|
||||
function handleMediaUpdate(updatedMedia: any) {
|
||||
// Update the photo in the album photos list
|
||||
const photoIndex = albumPhotos.findIndex(
|
||||
(photo) => (photo.mediaId || photo.id) === updatedMedia.id
|
||||
)
|
||||
if (photoIndex !== -1) {
|
||||
albumPhotos[photoIndex] = {
|
||||
...albumPhotos[photoIndex],
|
||||
filename: updatedMedia.filename,
|
||||
altText: updatedMedia.altText,
|
||||
description: updatedMedia.description,
|
||||
isPhotography: updatedMedia.isPhotography
|
||||
}
|
||||
albumPhotos = [...albumPhotos] // Trigger reactivity
|
||||
}
|
||||
selectedMedia = updatedMedia
|
||||
}
|
||||
|
||||
function handlePhotoReorder(reorderedPhotos: any[]) {
|
||||
albumPhotos = reorderedPhotos
|
||||
}
|
||||
|
||||
function handleGalleryAdd(newPhotos: any[]) {
|
||||
if (newPhotos.length > 0) {
|
||||
albumPhotos = [...albumPhotos, ...newPhotos]
|
||||
}
|
||||
}
|
||||
|
||||
function handleGalleryRemove(itemToRemove: any, index: number) {
|
||||
albumPhotos = albumPhotos.filter((_, i) => i !== index)
|
||||
}
|
||||
|
||||
// Metadata popover handlers
|
||||
function handleMetadataUpdate(key: string, value: any) {
|
||||
if (key === 'date') {
|
||||
date = value ? new Date(value).toISOString().split('T')[0] : ''
|
||||
} else {
|
||||
// Update the form state variable
|
||||
switch (key) {
|
||||
case 'slug':
|
||||
slug = value
|
||||
break
|
||||
case 'location':
|
||||
location = value
|
||||
break
|
||||
case 'isPhotography':
|
||||
isPhotography = value
|
||||
break
|
||||
case 'showInUniverse':
|
||||
showInUniverse = value
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mock album object for metadata popover
|
||||
const mockAlbum = $derived({
|
||||
id: null,
|
||||
title,
|
||||
slug,
|
||||
description,
|
||||
date: date ? new Date(date).toISOString() : null,
|
||||
location,
|
||||
isPhotography,
|
||||
showInUniverse,
|
||||
status,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
})
|
||||
|
||||
const canSave = $derived(title.trim().length > 0 && slug.trim().length > 0)
|
||||
</script>
|
||||
|
||||
|
|
@ -115,11 +233,40 @@
|
|||
</button>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<PublishDropdown
|
||||
onPublish={() => handleSave('published')}
|
||||
onSaveDraft={() => handleSave('draft')}
|
||||
disabled={!canSave || isSaving}
|
||||
<div class="metadata-popover-container">
|
||||
<button
|
||||
bind:this={metadataButtonElement}
|
||||
class="btn btn-text"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation()
|
||||
isMetadataOpen = !isMetadataOpen
|
||||
}}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 56 56" fill="none">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M 36.4023 19.3164 C 38.8398 19.3164 40.9257 17.7461 41.6992 15.5898 L 49.8085 15.5898 C 50.7695 15.5898 51.6133 14.7461 51.6133 13.6914 C 51.6133 12.6367 50.7695 11.8164 49.8085 11.8164 L 41.7226 11.8164 C 40.9257 9.6367 38.8398 8.0430 36.4023 8.0430 C 33.9648 8.0430 31.8789 9.6367 31.1054 11.8164 L 6.2851 11.8164 C 5.2304 11.8164 4.3867 12.6367 4.3867 13.6914 C 4.3867 14.7461 5.2304 15.5898 6.2851 15.5898 L 31.1054 15.5898 C 31.8789 17.7461 33.9648 19.3164 36.4023 19.3164 Z M 6.1913 26.1133 C 5.2304 26.1133 4.3867 26.9570 4.3867 28.0117 C 4.3867 29.0664 5.2304 29.8867 6.1913 29.8867 L 14.5586 29.8867 C 15.3320 32.0898 17.4179 33.6601 19.8554 33.6601 C 22.3164 33.6601 24.4023 32.0898 25.1757 29.8867 L 49.7149 29.8867 C 50.7695 29.8867 51.6133 29.0664 51.6133 28.0117 C 51.6133 26.9570 50.7695 26.1133 49.7149 26.1133 L 25.1757 26.1133 C 24.3789 23.9570 22.2929 22.3867 19.8554 22.3867 C 17.4413 22.3867 15.3554 23.9570 14.5586 26.1133 Z M 36.4023 47.9570 C 38.8398 47.9570 40.9257 46.3867 41.6992 44.2070 L 49.8085 44.2070 C 50.7695 44.2070 51.6133 43.3867 51.6133 42.3320 C 51.6133 41.2773 50.7695 40.4336 49.8085 40.4336 L 41.6992 40.4336 C 40.9257 38.2539 38.8398 36.7070 36.4023 36.7070 C 33.9648 36.7070 31.8789 38.2539 31.1054 40.4336 L 6.2851 40.4336 C 5.2304 40.4336 4.3867 41.2773 4.3867 42.3320 C 4.3867 43.3867 5.2304 44.2070 6.2851 44.2070 L 31.1054 44.2070 C 31.8789 46.3867 33.9648 47.9570 36.4023 47.9570 Z"
|
||||
/>
|
||||
</svg>
|
||||
Metadata
|
||||
</button>
|
||||
|
||||
{#if isMetadataOpen && metadataButtonElement}
|
||||
<AlbumMetadataPopover
|
||||
album={mockAlbum}
|
||||
triggerElement={metadataButtonElement}
|
||||
onUpdate={handleMetadataUpdate}
|
||||
onDelete={() => {}}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<SaveActionsGroup
|
||||
{status}
|
||||
onSave={handleSave}
|
||||
disabled={isSaving}
|
||||
isLoading={isSaving}
|
||||
{canSave}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
|
@ -141,15 +288,6 @@
|
|||
fullWidth
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Slug"
|
||||
bind:value={slug}
|
||||
placeholder="album-url-slug"
|
||||
helpText="Used in the album URL. Auto-generated from title."
|
||||
disabled={isSaving}
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<Input
|
||||
type="textarea"
|
||||
label="Description"
|
||||
|
|
@ -159,69 +297,43 @@
|
|||
disabled={isSaving}
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<div class="form-row">
|
||||
<Input
|
||||
type="date"
|
||||
label="Date"
|
||||
bind:value={date}
|
||||
helpText="When was this album created or photos taken?"
|
||||
disabled={isSaving}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Location"
|
||||
bind:value={location}
|
||||
placeholder="Location where photos were taken"
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Photo Management -->
|
||||
<div class="form-section">
|
||||
<h2>Album Settings</h2>
|
||||
<h2>Photos ({albumPhotos.length})</h2>
|
||||
|
||||
<!-- Photography Toggle -->
|
||||
<FormFieldWrapper label="Album Type">
|
||||
<div class="photography-toggle">
|
||||
<label class="toggle-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={isPhotography}
|
||||
disabled={isSaving}
|
||||
class="toggle-input"
|
||||
/>
|
||||
<span class="toggle-slider"></span>
|
||||
<div class="toggle-content">
|
||||
<span class="toggle-title">Photography Album</span>
|
||||
<span class="toggle-description">Show this album in the photography experience</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</FormFieldWrapper>
|
||||
|
||||
<!-- Show in Universe Toggle -->
|
||||
<FormFieldWrapper label="Visibility">
|
||||
<div class="universe-toggle">
|
||||
<label class="toggle-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={showInUniverse}
|
||||
disabled={isSaving}
|
||||
class="toggle-input"
|
||||
/>
|
||||
<span class="toggle-slider"></span>
|
||||
<div class="toggle-content">
|
||||
<span class="toggle-title">Show in Universe</span>
|
||||
<span class="toggle-description">Display this album in the Universe feed</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</FormFieldWrapper>
|
||||
<GalleryUploader
|
||||
label="Album Photos"
|
||||
bind:value={albumPhotos}
|
||||
onUpload={handleGalleryAdd}
|
||||
onReorder={handlePhotoReorder}
|
||||
onRemove={handleGalleryRemove}
|
||||
showBrowseLibrary={true}
|
||||
placeholder="Add photos to this album by uploading or selecting from your media library"
|
||||
helpText="Drag photos to reorder them. Click on photos to edit metadata."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</AdminPage>
|
||||
|
||||
<!-- Media Library Modal -->
|
||||
<MediaLibraryModal
|
||||
bind:isOpen={isMediaLibraryOpen}
|
||||
mode="multiple"
|
||||
fileType="image"
|
||||
onSelect={handleGalleryAdd}
|
||||
onClose={handleMediaLibraryClose}
|
||||
/>
|
||||
|
||||
<!-- Media Details Modal -->
|
||||
<MediaDetailsModal
|
||||
bind:isOpen={isMediaDetailsOpen}
|
||||
media={selectedMedia}
|
||||
onClose={handleMediaDetailsClose}
|
||||
onUpdate={handleMediaUpdate}
|
||||
/>
|
||||
|
||||
<style lang="scss">
|
||||
@import '$styles/variables.scss';
|
||||
|
||||
|
|
@ -244,12 +356,6 @@
|
|||
gap: $unit-2x;
|
||||
}
|
||||
|
||||
.publish-dropdown {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: $unit-half;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
|
|
@ -269,6 +375,29 @@
|
|||
}
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
padding: $unit $unit-2x;
|
||||
border: none;
|
||||
background: none;
|
||||
color: $grey-40;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $unit;
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: $grey-90;
|
||||
color: $grey-10;
|
||||
}
|
||||
}
|
||||
|
||||
.metadata-popover-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.album-form {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
|
|
@ -298,85 +427,4 @@
|
|||
padding-bottom: $unit-2x;
|
||||
}
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: $unit-3x;
|
||||
|
||||
@include breakpoint('tablet') {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.photography-toggle,
|
||||
.universe-toggle {
|
||||
.toggle-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $unit-3x;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.toggle-input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
|
||||
&:checked + .toggle-slider {
|
||||
background-color: $blue-60;
|
||||
|
||||
&::before {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
}
|
||||
|
||||
&:disabled + .toggle-slider {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.toggle-slider {
|
||||
position: relative;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
background-color: $grey-80;
|
||||
border-radius: 12px;
|
||||
transition: background-color 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background-color: white;
|
||||
border-radius: 50%;
|
||||
transition: transform 0.2s ease;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.toggle-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-half;
|
||||
|
||||
.toggle-title {
|
||||
font-weight: 500;
|
||||
color: $grey-10;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.toggle-description {
|
||||
font-size: 0.75rem;
|
||||
color: $grey-50;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
import AdminPage from '$lib/components/admin/AdminPage.svelte'
|
||||
import Editor from '$lib/components/admin/Editor.svelte'
|
||||
import LoadingSpinner from '$lib/components/admin/LoadingSpinner.svelte'
|
||||
import MetadataPopover from '$lib/components/admin/MetadataPopover.svelte'
|
||||
import PostMetadataPopover from '$lib/components/admin/PostMetadataPopover.svelte'
|
||||
import DeleteConfirmationModal from '$lib/components/admin/DeleteConfirmationModal.svelte'
|
||||
import Button from '$lib/components/admin/Button.svelte'
|
||||
import SaveActionsGroup from '$lib/components/admin/SaveActionsGroup.svelte'
|
||||
|
|
@ -229,7 +229,7 @@
|
|||
</button>
|
||||
|
||||
{#if showMetadata && metadataButtonRef}
|
||||
<MetadataPopover
|
||||
<PostMetadataPopover
|
||||
{post}
|
||||
{postType}
|
||||
triggerElement={metadataButtonRef}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
import { onMount } from 'svelte'
|
||||
import AdminPage from '$lib/components/admin/AdminPage.svelte'
|
||||
import Editor from '$lib/components/admin/Editor.svelte'
|
||||
import MetadataPopover from '$lib/components/admin/MetadataPopover.svelte'
|
||||
import PostMetadataPopover from '$lib/components/admin/PostMetadataPopover.svelte'
|
||||
import Button from '$lib/components/admin/Button.svelte'
|
||||
import PublishDropdown from '$lib/components/admin/PublishDropdown.svelte'
|
||||
import type { JSONContent } from '@tiptap/core'
|
||||
|
|
@ -179,7 +179,7 @@
|
|||
</button>
|
||||
|
||||
{#if showMetadata && metadataButtonRef}
|
||||
<MetadataPopover
|
||||
<PostMetadataPopover
|
||||
post={mockPost}
|
||||
{postType}
|
||||
triggerElement={metadataButtonRef}
|
||||
|
|
|
|||
Loading…
Reference in a new issue