feat(admin): update album management UI for content support
- Update AlbumForm to use EnhancedComposer for content editing - Add AlbumSelector component for album selection workflows - Update AlbumListItem with improved styling and metadata display - Enhance album edit/create pages with new content capabilities - Add support for geolocation data in album forms - Improve form validation and error handling Modernizes the album management interface with rich content editing. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
8627b1d574
commit
e488107544
6 changed files with 1001 additions and 1934 deletions
|
|
@ -1,197 +1,216 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation'
|
import { goto } from '$app/navigation'
|
||||||
|
import { z } from 'zod'
|
||||||
import AdminPage from './AdminPage.svelte'
|
import AdminPage from './AdminPage.svelte'
|
||||||
import Button from './Button.svelte'
|
import AdminSegmentedControl from './AdminSegmentedControl.svelte'
|
||||||
import Input from './Input.svelte'
|
import Input from './Input.svelte'
|
||||||
import GalleryUploader from './GalleryUploader.svelte'
|
import Button from './Button.svelte'
|
||||||
import Editor from './Editor.svelte'
|
import StatusDropdown from './StatusDropdown.svelte'
|
||||||
|
import UnifiedMediaModal from './UnifiedMediaModal.svelte'
|
||||||
|
import SmartImage from '../SmartImage.svelte'
|
||||||
|
import EnhancedComposer from './EnhancedComposer.svelte'
|
||||||
|
import { authenticatedFetch } from '$lib/admin-auth'
|
||||||
|
import type { Album } from '@prisma/client'
|
||||||
import type { JSONContent } from '@tiptap/core'
|
import type { JSONContent } from '@tiptap/core'
|
||||||
import type { Media } from '@prisma/client'
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
postId?: number
|
album?: Album | null
|
||||||
initialData?: {
|
|
||||||
title?: string
|
|
||||||
slug?: string
|
|
||||||
content?: JSONContent
|
|
||||||
gallery?: Media[]
|
|
||||||
status: 'draft' | 'published'
|
|
||||||
tags?: string[]
|
|
||||||
}
|
|
||||||
mode: 'create' | 'edit'
|
mode: 'create' | 'edit'
|
||||||
}
|
}
|
||||||
|
|
||||||
let { postId, initialData, mode }: Props = $props()
|
let { album = null, mode }: Props = $props()
|
||||||
|
|
||||||
|
// Album schema for validation
|
||||||
|
const albumSchema = z.object({
|
||||||
|
title: z.string().min(1, 'Title is required'),
|
||||||
|
slug: z
|
||||||
|
.string()
|
||||||
|
.min(1, 'Slug is required')
|
||||||
|
.regex(/^[a-z0-9-]+$/, 'Slug must be lowercase letters, numbers, and hyphens only'),
|
||||||
|
location: z.string().optional(),
|
||||||
|
year: z.string().optional()
|
||||||
|
})
|
||||||
|
|
||||||
// State
|
// State
|
||||||
|
let isLoading = $state(mode === 'edit')
|
||||||
let isSaving = $state(false)
|
let isSaving = $state(false)
|
||||||
let error = $state('')
|
let error = $state('')
|
||||||
let status = $state<'draft' | 'published'>(initialData?.status || 'draft')
|
let successMessage = $state('')
|
||||||
|
let validationErrors = $state<Record<string, string>>({})
|
||||||
|
let showBulkAlbumModal = $state(false)
|
||||||
|
let albumMedia = $state<any[]>([])
|
||||||
|
let editorInstance = $state<any>()
|
||||||
|
let activeTab = $state('metadata')
|
||||||
|
|
||||||
|
const tabOptions = [
|
||||||
|
{ value: 'metadata', label: 'Metadata' },
|
||||||
|
{ value: 'content', label: 'Content' }
|
||||||
|
]
|
||||||
|
|
||||||
// Form data
|
// Form data
|
||||||
let title = $state(initialData?.title || '')
|
let formData = $state({
|
||||||
let slug = $state(initialData?.slug || '')
|
title: '',
|
||||||
let content = $state<JSONContent>({ type: 'doc', content: [] })
|
slug: '',
|
||||||
let gallery = $state<Media[]>([])
|
year: '',
|
||||||
let tags = $state(initialData?.tags?.join(', ') || '')
|
location: '',
|
||||||
|
showInUniverse: false,
|
||||||
|
status: 'draft' as 'draft' | 'published',
|
||||||
|
content: { type: 'doc', content: [{ type: 'paragraph' }] } as JSONContent
|
||||||
|
})
|
||||||
|
|
||||||
// Editor ref
|
// Watch for album changes and populate form data
|
||||||
let editorRef: any
|
|
||||||
|
|
||||||
// Auto-generate slug from title
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (title && !slug) {
|
if (album && mode === 'edit') {
|
||||||
slug = title
|
populateFormData(album)
|
||||||
|
loadAlbumMedia()
|
||||||
|
} else if (mode === 'create') {
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Watch for title changes and update slug
|
||||||
|
$effect(() => {
|
||||||
|
if (formData.title && mode === 'create') {
|
||||||
|
formData.slug = formData.title
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replace(/[^a-z0-9]+/g, '-')
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
.replace(/^-+|-+$/g, '')
|
.replace(/^-+|-+$/g, '')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Initialize data for edit mode
|
function populateFormData(data: Album) {
|
||||||
$effect(() => {
|
formData = {
|
||||||
if (initialData && mode === 'edit') {
|
title: data.title || '',
|
||||||
// Parse album content structure
|
slug: data.slug || '',
|
||||||
if (
|
year: data.date ? new Date(data.date).getFullYear().toString() : '',
|
||||||
initialData.content &&
|
location: data.location || '',
|
||||||
typeof initialData.content === 'object' &&
|
showInUniverse: data.showInUniverse || false,
|
||||||
'type' in initialData.content
|
status: (data.status as 'draft' | 'published') || 'draft',
|
||||||
) {
|
content: (data.content as JSONContent) || { type: 'doc', content: [{ type: 'paragraph' }] }
|
||||||
const albumContent = initialData.content as any
|
|
||||||
if (albumContent.type === 'album') {
|
|
||||||
// Album content structure: { type: 'album', gallery: [mediaIds], description: JSONContent }
|
|
||||||
if (albumContent.gallery) {
|
|
||||||
// Load media objects from IDs (we'll need to fetch these)
|
|
||||||
loadGalleryMedia(albumContent.gallery)
|
|
||||||
}
|
|
||||||
if (albumContent.description) {
|
|
||||||
content = albumContent.description
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Fallback to regular content
|
|
||||||
content = initialData.content || { type: 'doc', content: [] }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load gallery from initialData if provided directly
|
|
||||||
if (initialData.gallery) {
|
|
||||||
gallery = initialData.gallery
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
|
||||||
async function loadGalleryMedia(mediaIds: number[]) {
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAlbumMedia() {
|
||||||
|
if (!album) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const auth = localStorage.getItem('admin_auth')
|
const response = await authenticatedFetch(`/api/albums/${album.id}`)
|
||||||
if (!auth) return
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
albumMedia = data.media || []
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load album media:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const mediaPromises = mediaIds.map(async (id) => {
|
function validateForm() {
|
||||||
const response = await fetch(`/api/media/${id}`, {
|
try {
|
||||||
headers: { Authorization: `Basic ${auth}` }
|
albumSchema.parse({
|
||||||
})
|
title: formData.title,
|
||||||
if (response.ok) {
|
slug: formData.slug,
|
||||||
return await response.json()
|
location: formData.location || undefined,
|
||||||
}
|
year: formData.year || undefined
|
||||||
return null
|
|
||||||
})
|
})
|
||||||
|
validationErrors = {}
|
||||||
const mediaResults = await Promise.all(mediaPromises)
|
return true
|
||||||
gallery = mediaResults.filter((media) => media !== null)
|
} catch (err) {
|
||||||
} catch (error) {
|
if (err instanceof z.ZodError) {
|
||||||
console.error('Failed to load gallery media:', error)
|
const errors: Record<string, string> = {}
|
||||||
|
err.errors.forEach((e) => {
|
||||||
|
if (e.path[0]) {
|
||||||
|
errors[e.path[0].toString()] = e.message
|
||||||
|
}
|
||||||
|
})
|
||||||
|
validationErrors = errors
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validation
|
async function handleSave() {
|
||||||
let isValid = $derived(title.trim().length > 0 && gallery.length > 0)
|
if (!validateForm()) {
|
||||||
|
error = 'Please fix the validation errors'
|
||||||
function handleGalleryUpload(newMedia: Media[]) {
|
return
|
||||||
gallery = [...gallery, ...newMedia]
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function handleGalleryReorder(reorderedMedia: Media[]) {
|
|
||||||
gallery = reorderedMedia
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleEditorChange(newContent: JSONContent) {
|
|
||||||
content = newContent
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSave(newStatus: 'draft' | 'published' = status) {
|
|
||||||
if (!isValid) return
|
|
||||||
|
|
||||||
isSaving = true
|
|
||||||
error = ''
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const postData = {
|
isSaving = true
|
||||||
title: title.trim(),
|
error = ''
|
||||||
slug: slug,
|
successMessage = ''
|
||||||
postType: 'album',
|
|
||||||
status: newStatus,
|
const payload = {
|
||||||
content,
|
title: formData.title,
|
||||||
gallery: gallery.map((media) => media.id),
|
slug: formData.slug,
|
||||||
featuredImage: gallery.length > 0 ? gallery[0].id : undefined,
|
description: null,
|
||||||
tags: tags.trim() ? tags.split(',').map((tag) => tag.trim()) : []
|
date: formData.year || null,
|
||||||
|
location: formData.location || null,
|
||||||
|
showInUniverse: formData.showInUniverse,
|
||||||
|
status: formData.status,
|
||||||
|
content: formData.content
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = mode === 'edit' ? `/api/posts/${postId}` : '/api/posts'
|
const url = mode === 'edit' ? `/api/albums/${album?.id}` : '/api/albums'
|
||||||
const method = mode === 'edit' ? 'PUT' : 'POST'
|
const method = mode === 'edit' ? 'PUT' : 'POST'
|
||||||
|
|
||||||
const auth = localStorage.getItem('admin_auth')
|
const response = await authenticatedFetch(url, {
|
||||||
if (!auth) {
|
|
||||||
goto('/admin/login')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(url, {
|
|
||||||
method,
|
method,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json'
|
||||||
Authorization: `Basic ${auth}`
|
|
||||||
},
|
},
|
||||||
body: JSON.stringify(postData)
|
body: JSON.stringify(payload)
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorData = await response.text()
|
const errorData = await response.json()
|
||||||
throw new Error(`Failed to save album: ${errorData}`)
|
throw new Error(
|
||||||
|
errorData.message || `Failed to ${mode === 'edit' ? 'save' : 'create'} album`
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
status = newStatus
|
const savedAlbum = await response.json()
|
||||||
goto('/admin/posts')
|
|
||||||
|
if (mode === 'create') {
|
||||||
|
goto(`/admin/albums/${savedAlbum.id}/edit`)
|
||||||
|
} else if (mode === 'edit' && album) {
|
||||||
|
// Update the album object to reflect saved changes
|
||||||
|
album = savedAlbum
|
||||||
|
populateFormData(savedAlbum)
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = err instanceof Error ? err.message : 'Failed to save album'
|
error =
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: `Failed to ${mode === 'edit' ? 'save' : 'create'} album`
|
||||||
|
console.error(err)
|
||||||
} finally {
|
} finally {
|
||||||
isSaving = false
|
isSaving = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCancel() {
|
async function handleStatusChange(newStatus: string) {
|
||||||
if (hasChanges() && !confirm('Are you sure you want to cancel? Your changes will be lost.')) {
|
formData.status = newStatus as any
|
||||||
return
|
await handleSave()
|
||||||
}
|
|
||||||
goto('/admin/posts')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasChanges(): boolean {
|
async function handleBulkAlbumSave() {
|
||||||
if (mode === 'create') {
|
// Reload album to get updated photo count
|
||||||
return title.trim().length > 0 || gallery.length > 0 || tags.trim().length > 0
|
if (album && mode === 'edit') {
|
||||||
|
await loadAlbumMedia()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// For edit mode, compare with initial data
|
function handleContentUpdate(content: JSONContent) {
|
||||||
return (
|
formData.content = content
|
||||||
title !== (initialData?.title || '') ||
|
|
||||||
gallery !== (initialData?.gallery || []) ||
|
|
||||||
tags !== (initialData?.tags?.join(', ') || '')
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<AdminPage>
|
<AdminPage>
|
||||||
<header slot="header">
|
<header slot="header">
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<button class="btn-icon" onclick={handleCancel}>
|
<button class="btn-icon" onclick={() => goto('/admin/albums')} aria-label="Back to albums">
|
||||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||||
<path
|
<path
|
||||||
d="M12.5 15L7.5 10L12.5 5"
|
d="M12.5 15L7.5 10L12.5 5"
|
||||||
|
|
@ -202,121 +221,187 @@
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<h1>📸 {mode === 'create' ? 'New Album' : 'Edit Album'}</h1>
|
</div>
|
||||||
|
<div class="header-center">
|
||||||
|
<AdminSegmentedControl
|
||||||
|
options={tabOptions}
|
||||||
|
value={activeTab}
|
||||||
|
onChange={(value) => (activeTab = value)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
{#if mode === 'create'}
|
{#if !isLoading}
|
||||||
<Button variant="secondary" onclick={handleCancel} disabled={isSaving}>Cancel</Button>
|
<StatusDropdown
|
||||||
<Button
|
currentStatus={formData.status}
|
||||||
variant="secondary"
|
onStatusChange={handleStatusChange}
|
||||||
onclick={() => handleSave('draft')}
|
disabled={isSaving || (mode === 'create' && (!formData.title || !formData.slug))}
|
||||||
disabled={!isValid || isSaving}
|
isLoading={isSaving}
|
||||||
>
|
primaryAction={formData.status === 'published'
|
||||||
{isSaving ? 'Saving...' : 'Save Draft'}
|
? { label: 'Save', status: 'published' }
|
||||||
</Button>
|
: { label: 'Publish', status: 'published' }}
|
||||||
<Button
|
dropdownActions={[
|
||||||
variant="primary"
|
{ label: 'Save as Draft', status: 'draft', show: formData.status !== 'draft' }
|
||||||
onclick={() => handleSave('published')}
|
]}
|
||||||
disabled={!isValid || isSaving}
|
viewUrl={album?.slug ? `/photos/${album.slug}` : undefined}
|
||||||
>
|
/>
|
||||||
{isSaving ? 'Publishing...' : 'Publish Album'}
|
|
||||||
</Button>
|
|
||||||
{:else}
|
|
||||||
<Button variant="secondary" onclick={handleCancel} disabled={isSaving}>Cancel</Button>
|
|
||||||
<Button variant="primary" onclick={() => handleSave()} disabled={!isValid || isSaving}>
|
|
||||||
{isSaving ? 'Saving...' : 'Save Changes'}
|
|
||||||
</Button>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="album-form">
|
<div class="admin-container">
|
||||||
{#if error}
|
{#if isLoading}
|
||||||
<div class="error-message">
|
<div class="loading">Loading album...</div>
|
||||||
{error}
|
{:else}
|
||||||
</div>
|
{#if error}
|
||||||
{/if}
|
<div class="error-message">{error}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="form-content">
|
<div class="tab-panels">
|
||||||
<div class="form-section">
|
<!-- Metadata Panel -->
|
||||||
<Input
|
<div class="panel content-wrapper" class:active={activeTab === 'metadata'}>
|
||||||
label="Album Title"
|
<!-- Album Details -->
|
||||||
size="jumbo"
|
<div class="form-section">
|
||||||
bind:value={title}
|
<Input
|
||||||
placeholder="Enter album title"
|
label="Title"
|
||||||
required={true}
|
size="jumbo"
|
||||||
error={title.trim().length === 0 ? 'Title is required' : undefined}
|
bind:value={formData.title}
|
||||||
/>
|
placeholder="Album title"
|
||||||
|
required
|
||||||
|
error={validationErrors.title}
|
||||||
|
disabled={isSaving}
|
||||||
|
/>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
label="Slug"
|
label="Slug"
|
||||||
bind:value={slug}
|
bind:value={formData.slug}
|
||||||
placeholder="album-url-slug"
|
placeholder="url-friendly-name"
|
||||||
helpText="URL-friendly version of the title"
|
required
|
||||||
/>
|
error={validationErrors.slug}
|
||||||
</div>
|
disabled={isSaving || mode === 'edit'}
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="form-section">
|
<div class="form-grid">
|
||||||
<GalleryUploader
|
<Input
|
||||||
label="Album Photos"
|
label="Location"
|
||||||
bind:value={gallery}
|
bind:value={formData.location}
|
||||||
onUpload={handleGalleryUpload}
|
placeholder="e.g. Tokyo, Japan"
|
||||||
onReorder={handleGalleryReorder}
|
error={validationErrors.location}
|
||||||
required={true}
|
disabled={isSaving}
|
||||||
showBrowseLibrary={true}
|
/>
|
||||||
maxItems={50}
|
<Input
|
||||||
placeholder="Add photos to your album"
|
label="Year"
|
||||||
helpText="First photo will be used as the album cover"
|
type="text"
|
||||||
error={gallery.length === 0 ? 'At least one photo is required' : undefined}
|
bind:value={formData.year}
|
||||||
/>
|
placeholder="e.g. 2023 or 2023-2025"
|
||||||
</div>
|
error={validationErrors.year}
|
||||||
|
disabled={isSaving}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-section">
|
<!-- Display Settings -->
|
||||||
<div class="editor-wrapper">
|
<div class="form-section">
|
||||||
<label class="form-label">Description</label>
|
<label class="toggle-label">
|
||||||
<Editor
|
<input
|
||||||
bind:this={editorRef}
|
type="checkbox"
|
||||||
bind:data={content}
|
bind:checked={formData.showInUniverse}
|
||||||
onChange={handleEditorChange}
|
disabled={isSaving}
|
||||||
placeholder="Write a description for your album..."
|
class="toggle-input"
|
||||||
simpleMode={false}
|
/>
|
||||||
minHeight={200}
|
<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>
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Photos Grid -->
|
||||||
|
{#if mode === 'edit'}
|
||||||
|
<div class="form-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h3 class="section-title">
|
||||||
|
Photos {albumMedia.length > 0 ? `(${albumMedia.length})` : ''}
|
||||||
|
</h3>
|
||||||
|
<button class="btn-secondary" onclick={() => (showBulkAlbumModal = true)}>
|
||||||
|
Manage Photos
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{#if albumMedia.length > 0}
|
||||||
|
<div class="photos-grid">
|
||||||
|
{#each albumMedia as item}
|
||||||
|
<div class="photo-item">
|
||||||
|
<SmartImage
|
||||||
|
media={item.media}
|
||||||
|
alt={item.media.description || item.media.filename}
|
||||||
|
sizes="(max-width: 768px) 50vw, 25vw"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="empty-state">
|
||||||
|
No photos added yet. Click "Manage Photos" to add photos to this album.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content Panel -->
|
||||||
|
<div class="panel panel-content" class:active={activeTab === 'content'}>
|
||||||
|
<EnhancedComposer
|
||||||
|
bind:this={editorInstance}
|
||||||
|
bind:data={formData.content}
|
||||||
|
placeholder="Add album content..."
|
||||||
|
onChange={handleContentUpdate}
|
||||||
|
editable={!isSaving}
|
||||||
|
albumId={album?.id}
|
||||||
|
variant="full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
<div class="form-section">
|
|
||||||
<Input
|
|
||||||
label="Tags"
|
|
||||||
bind:value={tags}
|
|
||||||
placeholder="travel, photography, nature"
|
|
||||||
helpText="Separate tags with commas"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</AdminPage>
|
</AdminPage>
|
||||||
|
|
||||||
|
<!-- Media Modal -->
|
||||||
|
{#if album && mode === 'edit'}
|
||||||
|
<UnifiedMediaModal
|
||||||
|
bind:isOpen={showBulkAlbumModal}
|
||||||
|
albumId={album.id}
|
||||||
|
showInAlbumMode={true}
|
||||||
|
onSave={handleBulkAlbumSave}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
@import '$styles/variables.scss';
|
header {
|
||||||
|
display: grid;
|
||||||
.header-left {
|
grid-template-columns: 250px 1fr 250px;
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
gap: $unit-2x;
|
gap: $unit-2x;
|
||||||
|
|
||||||
h1 {
|
.header-left {
|
||||||
font-size: 1.5rem;
|
width: 250px;
|
||||||
font-weight: 700;
|
display: flex;
|
||||||
margin: 0;
|
align-items: center;
|
||||||
color: $grey-10;
|
gap: $unit-2x;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.header-actions {
|
.header-center {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
justify-content: center;
|
||||||
gap: $unit-2x;
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
width: 250px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: $unit-2x;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-icon {
|
.btn-icon {
|
||||||
|
|
@ -338,51 +423,226 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.album-form {
|
.admin-container {
|
||||||
max-width: 800px;
|
width: 100%;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: $unit-3x;
|
padding: 0 $unit-2x $unit-4x;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
@include breakpoint('phone') {
|
||||||
|
padding: 0 $unit-2x $unit-2x;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-panels {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
display: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-wrapper {
|
||||||
|
background: white;
|
||||||
|
border-radius: $unit-2x;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
|
||||||
|
@include breakpoint('phone') {
|
||||||
|
padding: $unit-3x;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: $unit-6x;
|
||||||
|
color: $grey-40;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-message {
|
.error-message {
|
||||||
background: #fef2f2;
|
background-color: #fee;
|
||||||
border: 1px solid #fecaca;
|
color: #d33;
|
||||||
border-radius: 8px;
|
padding: $unit-3x;
|
||||||
padding: $unit-2x;
|
border-radius: $unit;
|
||||||
margin-bottom: $unit-3x;
|
margin-bottom: $unit-4x;
|
||||||
color: #dc2626;
|
max-width: 700px;
|
||||||
font-size: 0.875rem;
|
margin-left: auto;
|
||||||
}
|
margin-right: auto;
|
||||||
|
|
||||||
.form-content {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: $unit-4x;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-section {
|
.form-section {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
gap: $unit-4x;
|
||||||
|
|
||||||
|
&:not(:last-child) {
|
||||||
|
margin-bottom: $unit-6x;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-wrapper {
|
.section-title {
|
||||||
.form-label {
|
font-size: 1.125rem;
|
||||||
display: block;
|
font-weight: 600;
|
||||||
|
color: $grey-10;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: $unit-3x;
|
||||||
|
|
||||||
|
@include breakpoint('phone') {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.photos-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
gap: $unit-2x;
|
||||||
|
|
||||||
|
@include breakpoint('phone') {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-item {
|
||||||
|
aspect-ratio: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: $unit;
|
||||||
|
background: $grey-95;
|
||||||
|
|
||||||
|
:global(img) {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-content {
|
||||||
|
background: white;
|
||||||
|
padding: 0;
|
||||||
|
min-height: 80vh;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
@include breakpoint('phone') {
|
||||||
|
min-height: 600px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle styles
|
||||||
|
.toggle-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: $unit-3x;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-input {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
&:checked + .toggle-content + .toggle-slider {
|
||||||
|
background-color: $blue-60;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
transform: translateX(20px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled + .toggle-content + .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;
|
font-size: 0.875rem;
|
||||||
font-weight: 600;
|
}
|
||||||
color: $grey-20;
|
|
||||||
margin-bottom: $unit;
|
.toggle-description {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: $grey-50;
|
||||||
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@include breakpoint('phone') {
|
// Button styles
|
||||||
.album-form {
|
.btn-secondary {
|
||||||
padding: $unit-2x;
|
padding: $unit $unit-2x;
|
||||||
|
border: 1px solid $grey-80;
|
||||||
|
background: white;
|
||||||
|
color: $grey-20;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $grey-95;
|
||||||
|
border-color: $grey-70;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-actions {
|
&:disabled {
|
||||||
flex-wrap: wrap;
|
opacity: 0.5;
|
||||||
gap: $unit;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
color: $grey-50;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
text-align: center;
|
||||||
|
padding: $unit-4x;
|
||||||
|
background: $grey-95;
|
||||||
|
border-radius: $unit;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -18,15 +18,15 @@
|
||||||
date: string | null
|
date: string | null
|
||||||
location: string | null
|
location: string | null
|
||||||
coverPhotoId: number | null
|
coverPhotoId: number | null
|
||||||
isPhotography: boolean
|
|
||||||
status: string
|
status: string
|
||||||
showInUniverse: boolean
|
showInUniverse: boolean
|
||||||
publishedAt: string | null
|
publishedAt: string | null
|
||||||
createdAt: string
|
createdAt: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
photos: Photo[]
|
photos: Photo[]
|
||||||
|
content?: any
|
||||||
_count: {
|
_count: {
|
||||||
photos: number
|
media: number
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -105,7 +105,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPhotoCount(): number {
|
function getPhotoCount(): number {
|
||||||
return album._count?.photos || 0
|
return album._count?.media || 0
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -135,9 +135,10 @@
|
||||||
<h3 class="album-title">{album.title}</h3>
|
<h3 class="album-title">{album.title}</h3>
|
||||||
<AdminByline
|
<AdminByline
|
||||||
sections={[
|
sections={[
|
||||||
album.isPhotography ? 'Photography' : 'Album',
|
'Album',
|
||||||
album.status === 'published' ? 'Published' : 'Draft',
|
album.status === 'published' ? 'Published' : 'Draft',
|
||||||
`${getPhotoCount()} ${getPhotoCount() === 1 ? 'photo' : 'photos'}`,
|
`${getPhotoCount()} ${getPhotoCount() === 1 ? 'photo' : 'photos'}`,
|
||||||
|
...(album.content ? ['📖 Story'] : []),
|
||||||
album.status === 'published' && album.publishedAt
|
album.status === 'published' && album.publishedAt
|
||||||
? `Published ${formatRelativeTime(album.publishedAt)}`
|
? `Published ${formatRelativeTime(album.publishedAt)}`
|
||||||
: `Created ${formatRelativeTime(album.createdAt)}`
|
: `Created ${formatRelativeTime(album.createdAt)}`
|
||||||
|
|
|
||||||
441
src/lib/components/admin/AlbumSelector.svelte
Normal file
441
src/lib/components/admin/AlbumSelector.svelte
Normal file
|
|
@ -0,0 +1,441 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte'
|
||||||
|
import Button from './Button.svelte'
|
||||||
|
import Input from './Input.svelte'
|
||||||
|
import LoadingSpinner from './LoadingSpinner.svelte'
|
||||||
|
|
||||||
|
interface Album {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
slug: string
|
||||||
|
_count?: {
|
||||||
|
media: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
mediaId: number
|
||||||
|
currentAlbums: Album[]
|
||||||
|
onUpdate: (albums: Album[]) => void
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
let { mediaId, currentAlbums = [], onUpdate, onClose }: Props = $props()
|
||||||
|
|
||||||
|
// State
|
||||||
|
let albums = $state<Album[]>([])
|
||||||
|
let filteredAlbums = $state<Album[]>([])
|
||||||
|
let selectedAlbumIds = $state<Set<number>>(new Set(currentAlbums.map((a) => a.id)))
|
||||||
|
let isLoading = $state(true)
|
||||||
|
let isSaving = $state(false)
|
||||||
|
let error = $state('')
|
||||||
|
let searchQuery = $state('')
|
||||||
|
let showCreateNew = $state(false)
|
||||||
|
let newAlbumTitle = $state('')
|
||||||
|
let newAlbumSlug = $state('')
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
loadAlbums()
|
||||||
|
})
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (searchQuery) {
|
||||||
|
filteredAlbums = albums.filter((album) =>
|
||||||
|
album.title.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
filteredAlbums = albums
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (newAlbumTitle) {
|
||||||
|
// Auto-generate slug from title
|
||||||
|
newAlbumSlug = newAlbumTitle
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadAlbums() {
|
||||||
|
try {
|
||||||
|
isLoading = true
|
||||||
|
const auth = localStorage.getItem('admin_auth')
|
||||||
|
if (!auth) return
|
||||||
|
|
||||||
|
const response = await fetch('/api/albums', {
|
||||||
|
headers: { Authorization: `Basic ${auth}` }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to load albums')
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
albums = data.albums || []
|
||||||
|
filteredAlbums = albums
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load albums:', err)
|
||||||
|
error = 'Failed to load albums'
|
||||||
|
} finally {
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAlbum(albumId: number) {
|
||||||
|
if (selectedAlbumIds.has(albumId)) {
|
||||||
|
selectedAlbumIds.delete(albumId)
|
||||||
|
} else {
|
||||||
|
selectedAlbumIds.add(albumId)
|
||||||
|
}
|
||||||
|
selectedAlbumIds = new Set(selectedAlbumIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createNewAlbum() {
|
||||||
|
if (!newAlbumTitle.trim() || !newAlbumSlug.trim()) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
isSaving = true
|
||||||
|
error = ''
|
||||||
|
const auth = localStorage.getItem('admin_auth')
|
||||||
|
if (!auth) return
|
||||||
|
|
||||||
|
const response = await fetch('/api/albums', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Basic ${auth}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: newAlbumTitle.trim(),
|
||||||
|
slug: newAlbumSlug.trim(),
|
||||||
|
isPhotography: true,
|
||||||
|
status: 'draft'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json()
|
||||||
|
throw new Error(errorData.message || 'Failed to create album')
|
||||||
|
}
|
||||||
|
|
||||||
|
const newAlbum = await response.json()
|
||||||
|
|
||||||
|
// Add to albums list and select it
|
||||||
|
albums = [newAlbum, ...albums]
|
||||||
|
selectedAlbumIds.add(newAlbum.id)
|
||||||
|
selectedAlbumIds = new Set(selectedAlbumIds)
|
||||||
|
|
||||||
|
// Reset form
|
||||||
|
showCreateNew = false
|
||||||
|
newAlbumTitle = ''
|
||||||
|
newAlbumSlug = ''
|
||||||
|
searchQuery = ''
|
||||||
|
} catch (err) {
|
||||||
|
error = err instanceof Error ? err.message : 'Failed to create album'
|
||||||
|
} finally {
|
||||||
|
isSaving = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
try {
|
||||||
|
isSaving = true
|
||||||
|
error = ''
|
||||||
|
const auth = localStorage.getItem('admin_auth')
|
||||||
|
if (!auth) return
|
||||||
|
|
||||||
|
// Get the list of albums to add/remove
|
||||||
|
const currentAlbumIds = new Set(currentAlbums.map((a) => a.id))
|
||||||
|
const albumsToAdd = Array.from(selectedAlbumIds).filter((id) => !currentAlbumIds.has(id))
|
||||||
|
const albumsToRemove = currentAlbums
|
||||||
|
.filter((a) => !selectedAlbumIds.has(a.id))
|
||||||
|
.map((a) => a.id)
|
||||||
|
|
||||||
|
// Add to new albums
|
||||||
|
for (const albumId of albumsToAdd) {
|
||||||
|
const response = await fetch(`/api/albums/${albumId}/media`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Basic ${auth}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ mediaIds: [mediaId] })
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to add to album')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from albums
|
||||||
|
for (const albumId of albumsToRemove) {
|
||||||
|
const response = await fetch(`/api/albums/${albumId}/media`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Basic ${auth}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ mediaIds: [mediaId] })
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to remove from album')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get updated album list
|
||||||
|
const updatedAlbums = albums.filter((a) => selectedAlbumIds.has(a.id))
|
||||||
|
onUpdate(updatedAlbums)
|
||||||
|
onClose()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to update albums:', err)
|
||||||
|
error = 'Failed to update albums'
|
||||||
|
} finally {
|
||||||
|
isSaving = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
const hasChanges = $derived(() => {
|
||||||
|
const currentIds = new Set(currentAlbums.map((a) => a.id))
|
||||||
|
if (currentIds.size !== selectedAlbumIds.size) return true
|
||||||
|
for (const id of selectedAlbumIds) {
|
||||||
|
if (!currentIds.has(id)) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="album-selector">
|
||||||
|
<div class="selector-header">
|
||||||
|
<h3>Manage Albums</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="error-message">{error}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="selector-content">
|
||||||
|
{#if !showCreateNew}
|
||||||
|
<div class="search-section">
|
||||||
|
<Input type="search" bind:value={searchQuery} placeholder="Search albums..." fullWidth />
|
||||||
|
<Button variant="ghost" onclick={() => (showCreateNew = true)} buttonSize="small">
|
||||||
|
<svg slot="icon" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||||
|
<path
|
||||||
|
d="M8 3v10M3 8h10"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
New Album
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if isLoading}
|
||||||
|
<div class="loading-state">
|
||||||
|
<LoadingSpinner />
|
||||||
|
<p>Loading albums...</p>
|
||||||
|
</div>
|
||||||
|
{:else if filteredAlbums.length === 0}
|
||||||
|
<div class="empty-state">
|
||||||
|
<p>{searchQuery ? 'No albums found' : 'No albums available'}</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="album-grid">
|
||||||
|
{#each filteredAlbums as album}
|
||||||
|
<label class="album-option">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedAlbumIds.has(album.id)}
|
||||||
|
onchange={() => toggleAlbum(album.id)}
|
||||||
|
/>
|
||||||
|
<div class="album-info">
|
||||||
|
<span class="album-title">{album.title}</span>
|
||||||
|
<span class="album-meta">
|
||||||
|
{album._count?.media || 0} photos
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<div class="create-new-form">
|
||||||
|
<h4>Create New Album</h4>
|
||||||
|
<Input
|
||||||
|
label="Album Title"
|
||||||
|
bind:value={newAlbumTitle}
|
||||||
|
placeholder="My New Album"
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
<Input label="URL Slug" bind:value={newAlbumSlug} placeholder="my-new-album" fullWidth />
|
||||||
|
<div class="form-actions">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onclick={() => {
|
||||||
|
showCreateNew = false
|
||||||
|
newAlbumTitle = ''
|
||||||
|
newAlbumSlug = ''
|
||||||
|
}}
|
||||||
|
disabled={isSaving}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onclick={createNewAlbum}
|
||||||
|
disabled={!newAlbumTitle.trim() || !newAlbumSlug.trim() || isSaving}
|
||||||
|
>
|
||||||
|
{isSaving ? 'Creating...' : 'Create Album'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if !showCreateNew}
|
||||||
|
<div class="selector-footer">
|
||||||
|
<Button variant="ghost" onclick={onClose}>Cancel</Button>
|
||||||
|
<Button variant="primary" onclick={handleSave} disabled={!hasChanges() || isSaving}>
|
||||||
|
{isSaving ? 'Saving...' : 'Save Changes'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.album-selector {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
background: white;
|
||||||
|
border-radius: $unit-2x;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selector-header {
|
||||||
|
padding: $unit-3x;
|
||||||
|
border-bottom: 1px solid $grey-85;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $grey-10;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
margin: $unit-2x $unit-3x 0;
|
||||||
|
padding: $unit-2x;
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
color: #dc2626;
|
||||||
|
border-radius: $unit;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selector-content {
|
||||||
|
flex: 1;
|
||||||
|
padding: $unit-3x;
|
||||||
|
overflow-y: auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-section {
|
||||||
|
display: flex;
|
||||||
|
gap: $unit-2x;
|
||||||
|
margin-bottom: $unit-3x;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-state,
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: $unit-6x;
|
||||||
|
text-align: center;
|
||||||
|
color: $grey-40;
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: $unit-2x 0 0 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $unit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $unit-2x;
|
||||||
|
padding: $unit-2x;
|
||||||
|
background: $grey-95;
|
||||||
|
border-radius: $unit;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $grey-90;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='checkbox'] {
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-title {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: $grey-10;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-meta {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: $grey-40;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-new-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $unit-3x;
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $grey-10;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: $unit-2x;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selector-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: $unit-2x;
|
||||||
|
padding: $unit-3x;
|
||||||
|
border-top: 1px solid $grey-85;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -24,15 +24,15 @@
|
||||||
date: string | null
|
date: string | null
|
||||||
location: string | null
|
location: string | null
|
||||||
coverPhotoId: number | null
|
coverPhotoId: number | null
|
||||||
isPhotography: boolean
|
|
||||||
status: string
|
status: string
|
||||||
showInUniverse: boolean
|
showInUniverse: boolean
|
||||||
publishedAt: string | null
|
publishedAt: string | null
|
||||||
createdAt: string
|
createdAt: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
photos: Photo[]
|
photos: Photo[]
|
||||||
|
content?: any
|
||||||
_count: {
|
_count: {
|
||||||
photos: number
|
media: number
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -48,14 +48,14 @@
|
||||||
let activeDropdown = $state<number | null>(null)
|
let activeDropdown = $state<number | null>(null)
|
||||||
|
|
||||||
// Filter state
|
// Filter state
|
||||||
let photographyFilter = $state<string>('all')
|
let statusFilter = $state<string>('all')
|
||||||
let sortBy = $state<string>('newest')
|
let sortBy = $state<string>('newest')
|
||||||
|
|
||||||
// Filter options
|
// Filter options
|
||||||
const filterOptions = [
|
const filterOptions = [
|
||||||
{ value: 'all', label: 'All albums' },
|
{ value: 'all', label: 'All albums' },
|
||||||
{ value: 'true', label: 'Photography albums' },
|
{ value: 'published', label: 'Published' },
|
||||||
{ value: 'false', label: 'Regular albums' }
|
{ value: 'draft', label: 'Drafts' }
|
||||||
]
|
]
|
||||||
|
|
||||||
const sortOptions = [
|
const sortOptions = [
|
||||||
|
|
@ -107,11 +107,11 @@
|
||||||
albums = data.albums || []
|
albums = data.albums || []
|
||||||
total = data.pagination?.total || albums.length
|
total = data.pagination?.total || albums.length
|
||||||
|
|
||||||
// Calculate album type counts
|
// Calculate album status counts
|
||||||
const counts: Record<string, number> = {
|
const counts: Record<string, number> = {
|
||||||
all: albums.length,
|
all: albums.length,
|
||||||
photography: albums.filter((a) => a.isPhotography).length,
|
published: albums.filter((a) => a.status === 'published').length,
|
||||||
regular: albums.filter((a) => !a.isPhotography).length
|
draft: albums.filter((a) => a.status === 'draft').length
|
||||||
}
|
}
|
||||||
albumTypeCounts = counts
|
albumTypeCounts = counts
|
||||||
|
|
||||||
|
|
@ -129,10 +129,10 @@
|
||||||
let filtered = [...albums]
|
let filtered = [...albums]
|
||||||
|
|
||||||
// Apply filter
|
// Apply filter
|
||||||
if (photographyFilter === 'true') {
|
if (statusFilter === 'published') {
|
||||||
filtered = filtered.filter((album) => album.isPhotography === true)
|
filtered = filtered.filter((album) => album.status === 'published')
|
||||||
} else if (photographyFilter === 'false') {
|
} else if (statusFilter === 'draft') {
|
||||||
filtered = filtered.filter((album) => album.isPhotography === false)
|
filtered = filtered.filter((album) => album.status === 'draft')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply sorting
|
// Apply sorting
|
||||||
|
|
@ -289,7 +289,7 @@
|
||||||
<AdminFilters>
|
<AdminFilters>
|
||||||
{#snippet left()}
|
{#snippet left()}
|
||||||
<Select
|
<Select
|
||||||
bind:value={photographyFilter}
|
bind:value={statusFilter}
|
||||||
options={filterOptions}
|
options={filterOptions}
|
||||||
size="small"
|
size="small"
|
||||||
variant="minimal"
|
variant="minimal"
|
||||||
|
|
@ -316,7 +316,7 @@
|
||||||
{:else if filteredAlbums.length === 0}
|
{:else if filteredAlbums.length === 0}
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<p>
|
<p>
|
||||||
{#if photographyFilter === 'all'}
|
{#if statusFilter === 'all'}
|
||||||
No albums found. Create your first album!
|
No albums found. Create your first album!
|
||||||
{:else}
|
{:else}
|
||||||
No albums found matching the current filters. Try adjusting your filters or create a new
|
No albums found matching the current filters. Try adjusting your filters or create a new
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,498 +1,9 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation'
|
import AlbumForm from '$lib/components/admin/AlbumForm.svelte'
|
||||||
import AdminPage from '$lib/components/admin/AdminPage.svelte'
|
|
||||||
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 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('')
|
|
||||||
let slug = $state('')
|
|
||||||
let description = $state('')
|
|
||||||
let date = $state('')
|
|
||||||
let location = $state('')
|
|
||||||
let isPhotography = $state(false)
|
|
||||||
let showInUniverse = $state(false)
|
|
||||||
let status = $state<'draft' | 'published'>('draft')
|
|
||||||
|
|
||||||
// UI state
|
|
||||||
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) {
|
|
||||||
slug = generateSlug(title)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
function generateSlug(text: string): string {
|
|
||||||
return text
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9]+/g, '-')
|
|
||||||
.replace(/^-+|-+$/g, '')
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSave(publishStatus: 'draft' | 'published') {
|
|
||||||
if (!title.trim()) {
|
|
||||||
error = 'Title is required'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!slug.trim()) {
|
|
||||||
error = 'Slug is required'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
isSaving = true
|
|
||||||
error = ''
|
|
||||||
|
|
||||||
const auth = localStorage.getItem('admin_auth')
|
|
||||||
if (!auth) {
|
|
||||||
goto('/admin/login')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const albumData = {
|
|
||||||
title: title.trim(),
|
|
||||||
slug: slug.trim(),
|
|
||||||
description: description.trim() || null,
|
|
||||||
date: date ? new Date(date).toISOString() : null,
|
|
||||||
location: location.trim() || null,
|
|
||||||
isPhotography,
|
|
||||||
showInUniverse,
|
|
||||||
status: publishStatus
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch('/api/albums', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
Authorization: `Basic ${auth}`,
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify(albumData)
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json()
|
|
||||||
throw new Error(errorData.message || 'Failed to create album')
|
|
||||||
}
|
|
||||||
|
|
||||||
const album = await response.json()
|
|
||||||
|
|
||||||
// Add selected photos to the newly created album
|
|
||||||
if (albumPhotos.length > 0) {
|
|
||||||
console.log(`Adding ${albumPhotos.length} photos to newly created album ${album.id}`)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const addedPhotos = []
|
|
||||||
for (let i = 0; i < albumPhotos.length; i++) {
|
|
||||||
const media = albumPhotos[i]
|
|
||||||
console.log(`Adding photo ${media.id} to album ${album.id}`)
|
|
||||||
|
|
||||||
const photoResponse = await fetch(`/api/albums/${album.id}/photos`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
Authorization: `Basic ${auth}`,
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
mediaId: media.id,
|
|
||||||
displayOrder: i
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!photoResponse.ok) {
|
|
||||||
const errorData = await photoResponse.text()
|
|
||||||
console.error(
|
|
||||||
`Failed to add photo ${media.filename}:`,
|
|
||||||
photoResponse.status,
|
|
||||||
errorData
|
|
||||||
)
|
|
||||||
// Continue with other photos even if one fails
|
|
||||||
} else {
|
|
||||||
const photo = await photoResponse.json()
|
|
||||||
addedPhotos.push(photo)
|
|
||||||
console.log(`Successfully added photo ${photo.id} to album`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`Successfully added ${addedPhotos.length} out of ${albumPhotos.length} photos to album`
|
|
||||||
)
|
|
||||||
} catch (photoError) {
|
|
||||||
console.error('Error adding photos to album:', photoError)
|
|
||||||
// Don't fail the whole creation - just log the error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Redirect to album edit page
|
|
||||||
goto(`/admin/albums/${album.id}/edit`)
|
|
||||||
} catch (err) {
|
|
||||||
error = err instanceof Error ? err.message : 'Failed to create album'
|
|
||||||
console.error('Failed to create album:', err)
|
|
||||||
} finally {
|
|
||||||
isSaving = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleCancel() {
|
|
||||||
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>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>New Album - Admin @jedmund</title>
|
<title>New Album - Admin @jedmund</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<AdminPage>
|
<AlbumForm mode="create" />
|
||||||
<header slot="header">
|
|
||||||
<div class="header-left">
|
|
||||||
<button class="btn-icon" onclick={handleCancel}>
|
|
||||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
|
||||||
<path
|
|
||||||
d="M12.5 15L7.5 10L12.5 5"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="1.5"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="header-actions">
|
|
||||||
<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={() => {}}
|
|
||||||
onClose={() => (isMetadataOpen = false)}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<SaveActionsGroup
|
|
||||||
{status}
|
|
||||||
onSave={handleSave}
|
|
||||||
disabled={isSaving}
|
|
||||||
isLoading={isSaving}
|
|
||||||
{canSave}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="album-form">
|
|
||||||
{#if error}
|
|
||||||
<div class="error-message">{error}</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="form-section">
|
|
||||||
<h2>Album Details</h2>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
label="Title"
|
|
||||||
bind:value={title}
|
|
||||||
placeholder="Enter album title"
|
|
||||||
required
|
|
||||||
disabled={isSaving}
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
type="textarea"
|
|
||||||
label="Description"
|
|
||||||
bind:value={description}
|
|
||||||
placeholder="Describe this album..."
|
|
||||||
rows={3}
|
|
||||||
disabled={isSaving}
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Photo Management -->
|
|
||||||
<div class="form-section">
|
|
||||||
<h2>Photos ({albumPhotos.length})</h2>
|
|
||||||
|
|
||||||
<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';
|
|
||||||
|
|
||||||
.header-left {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: $unit-2x;
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: 700;
|
|
||||||
margin: 0;
|
|
||||||
color: $grey-10;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: $unit-2x;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-icon {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
color: $grey-40;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border-radius: 8px;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: $grey-90;
|
|
||||||
color: $grey-10;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.metadata-popover-container {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.album-form {
|
|
||||||
max-width: 800px;
|
|
||||||
margin: 0 auto;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: $unit-6x;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-message {
|
|
||||||
background: rgba(239, 68, 68, 0.1);
|
|
||||||
color: #dc2626;
|
|
||||||
padding: $unit-3x;
|
|
||||||
border-radius: $unit-2x;
|
|
||||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-section {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: $unit-4x;
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
font-size: 1.25rem;
|
|
||||||
font-weight: 600;
|
|
||||||
margin: 0;
|
|
||||||
color: $grey-10;
|
|
||||||
padding-bottom: $unit-2x;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue