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">
|
||||
import { goto } from '$app/navigation'
|
||||
import { z } from 'zod'
|
||||
import AdminPage from './AdminPage.svelte'
|
||||
import Button from './Button.svelte'
|
||||
import AdminSegmentedControl from './AdminSegmentedControl.svelte'
|
||||
import Input from './Input.svelte'
|
||||
import GalleryUploader from './GalleryUploader.svelte'
|
||||
import Editor from './Editor.svelte'
|
||||
import Button from './Button.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 { Media } from '@prisma/client'
|
||||
|
||||
interface Props {
|
||||
postId?: number
|
||||
initialData?: {
|
||||
title?: string
|
||||
slug?: string
|
||||
content?: JSONContent
|
||||
gallery?: Media[]
|
||||
status: 'draft' | 'published'
|
||||
tags?: string[]
|
||||
}
|
||||
album?: Album | null
|
||||
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
|
||||
let isLoading = $state(mode === 'edit')
|
||||
let isSaving = $state(false)
|
||||
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
|
||||
let title = $state(initialData?.title || '')
|
||||
let slug = $state(initialData?.slug || '')
|
||||
let content = $state<JSONContent>({ type: 'doc', content: [] })
|
||||
let gallery = $state<Media[]>([])
|
||||
let tags = $state(initialData?.tags?.join(', ') || '')
|
||||
let formData = $state({
|
||||
title: '',
|
||||
slug: '',
|
||||
year: '',
|
||||
location: '',
|
||||
showInUniverse: false,
|
||||
status: 'draft' as 'draft' | 'published',
|
||||
content: { type: 'doc', content: [{ type: 'paragraph' }] } as JSONContent
|
||||
})
|
||||
|
||||
// Editor ref
|
||||
let editorRef: any
|
||||
|
||||
// Auto-generate slug from title
|
||||
// Watch for album changes and populate form data
|
||||
$effect(() => {
|
||||
if (title && !slug) {
|
||||
slug = title
|
||||
if (album && mode === 'edit') {
|
||||
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()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
}
|
||||
})
|
||||
|
||||
// Initialize data for edit mode
|
||||
$effect(() => {
|
||||
if (initialData && mode === 'edit') {
|
||||
// Parse album content structure
|
||||
if (
|
||||
initialData.content &&
|
||||
typeof initialData.content === 'object' &&
|
||||
'type' in initialData.content
|
||||
) {
|
||||
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
|
||||
}
|
||||
function populateFormData(data: Album) {
|
||||
formData = {
|
||||
title: data.title || '',
|
||||
slug: data.slug || '',
|
||||
year: data.date ? new Date(data.date).getFullYear().toString() : '',
|
||||
location: data.location || '',
|
||||
showInUniverse: data.showInUniverse || false,
|
||||
status: (data.status as 'draft' | 'published') || 'draft',
|
||||
content: (data.content as JSONContent) || { type: 'doc', content: [{ type: 'paragraph' }] }
|
||||
}
|
||||
})
|
||||
|
||||
async function loadGalleryMedia(mediaIds: number[]) {
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
async function loadAlbumMedia() {
|
||||
if (!album) return
|
||||
|
||||
try {
|
||||
const auth = localStorage.getItem('admin_auth')
|
||||
if (!auth) return
|
||||
const response = await authenticatedFetch(`/api/albums/${album.id}`)
|
||||
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) => {
|
||||
const response = await fetch(`/api/media/${id}`, {
|
||||
headers: { Authorization: `Basic ${auth}` }
|
||||
})
|
||||
if (response.ok) {
|
||||
return await response.json()
|
||||
}
|
||||
return null
|
||||
function validateForm() {
|
||||
try {
|
||||
albumSchema.parse({
|
||||
title: formData.title,
|
||||
slug: formData.slug,
|
||||
location: formData.location || undefined,
|
||||
year: formData.year || undefined
|
||||
})
|
||||
|
||||
const mediaResults = await Promise.all(mediaPromises)
|
||||
gallery = mediaResults.filter((media) => media !== null)
|
||||
} catch (error) {
|
||||
console.error('Failed to load gallery media:', error)
|
||||
validationErrors = {}
|
||||
return true
|
||||
} catch (err) {
|
||||
if (err instanceof z.ZodError) {
|
||||
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
|
||||
let isValid = $derived(title.trim().length > 0 && gallery.length > 0)
|
||||
|
||||
function handleGalleryUpload(newMedia: Media[]) {
|
||||
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 = ''
|
||||
async function handleSave() {
|
||||
if (!validateForm()) {
|
||||
error = 'Please fix the validation errors'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const postData = {
|
||||
title: title.trim(),
|
||||
slug: slug,
|
||||
postType: 'album',
|
||||
status: newStatus,
|
||||
content,
|
||||
gallery: gallery.map((media) => media.id),
|
||||
featuredImage: gallery.length > 0 ? gallery[0].id : undefined,
|
||||
tags: tags.trim() ? tags.split(',').map((tag) => tag.trim()) : []
|
||||
isSaving = true
|
||||
error = ''
|
||||
successMessage = ''
|
||||
|
||||
const payload = {
|
||||
title: formData.title,
|
||||
slug: formData.slug,
|
||||
description: null,
|
||||
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 auth = localStorage.getItem('admin_auth')
|
||||
if (!auth) {
|
||||
goto('/admin/login')
|
||||
return
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
const response = await authenticatedFetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Basic ${auth}`
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(postData)
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.text()
|
||||
throw new Error(`Failed to save album: ${errorData}`)
|
||||
const errorData = await response.json()
|
||||
throw new Error(
|
||||
errorData.message || `Failed to ${mode === 'edit' ? 'save' : 'create'} album`
|
||||
)
|
||||
}
|
||||
|
||||
status = newStatus
|
||||
goto('/admin/posts')
|
||||
const savedAlbum = await response.json()
|
||||
|
||||
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) {
|
||||
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 {
|
||||
isSaving = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
if (hasChanges() && !confirm('Are you sure you want to cancel? Your changes will be lost.')) {
|
||||
return
|
||||
}
|
||||
goto('/admin/posts')
|
||||
async function handleStatusChange(newStatus: string) {
|
||||
formData.status = newStatus as any
|
||||
await handleSave()
|
||||
}
|
||||
|
||||
function hasChanges(): boolean {
|
||||
if (mode === 'create') {
|
||||
return title.trim().length > 0 || gallery.length > 0 || tags.trim().length > 0
|
||||
async function handleBulkAlbumSave() {
|
||||
// Reload album to get updated photo count
|
||||
if (album && mode === 'edit') {
|
||||
await loadAlbumMedia()
|
||||
}
|
||||
}
|
||||
|
||||
// For edit mode, compare with initial data
|
||||
return (
|
||||
title !== (initialData?.title || '') ||
|
||||
gallery !== (initialData?.gallery || []) ||
|
||||
tags !== (initialData?.tags?.join(', ') || '')
|
||||
)
|
||||
function handleContentUpdate(content: JSONContent) {
|
||||
formData.content = content
|
||||
}
|
||||
</script>
|
||||
|
||||
<AdminPage>
|
||||
<header slot="header">
|
||||
<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">
|
||||
<path
|
||||
d="M12.5 15L7.5 10L12.5 5"
|
||||
|
|
@ -202,121 +221,187 @@
|
|||
/>
|
||||
</svg>
|
||||
</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 class="header-actions">
|
||||
{#if mode === 'create'}
|
||||
<Button variant="secondary" onclick={handleCancel} disabled={isSaving}>Cancel</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onclick={() => handleSave('draft')}
|
||||
disabled={!isValid || isSaving}
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save Draft'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onclick={() => handleSave('published')}
|
||||
disabled={!isValid || isSaving}
|
||||
>
|
||||
{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 !isLoading}
|
||||
<StatusDropdown
|
||||
currentStatus={formData.status}
|
||||
onStatusChange={handleStatusChange}
|
||||
disabled={isSaving || (mode === 'create' && (!formData.title || !formData.slug))}
|
||||
isLoading={isSaving}
|
||||
primaryAction={formData.status === 'published'
|
||||
? { label: 'Save', status: 'published' }
|
||||
: { label: 'Publish', status: 'published' }}
|
||||
dropdownActions={[
|
||||
{ label: 'Save as Draft', status: 'draft', show: formData.status !== 'draft' }
|
||||
]}
|
||||
viewUrl={album?.slug ? `/photos/${album.slug}` : undefined}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="album-form">
|
||||
{#if error}
|
||||
<div class="error-message">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="admin-container">
|
||||
{#if isLoading}
|
||||
<div class="loading">Loading album...</div>
|
||||
{:else}
|
||||
{#if error}
|
||||
<div class="error-message">{error}</div>
|
||||
{/if}
|
||||
|
||||
<div class="form-content">
|
||||
<div class="form-section">
|
||||
<Input
|
||||
label="Album Title"
|
||||
size="jumbo"
|
||||
bind:value={title}
|
||||
placeholder="Enter album title"
|
||||
required={true}
|
||||
error={title.trim().length === 0 ? 'Title is required' : undefined}
|
||||
/>
|
||||
<div class="tab-panels">
|
||||
<!-- Metadata Panel -->
|
||||
<div class="panel content-wrapper" class:active={activeTab === 'metadata'}>
|
||||
<!-- Album Details -->
|
||||
<div class="form-section">
|
||||
<Input
|
||||
label="Title"
|
||||
size="jumbo"
|
||||
bind:value={formData.title}
|
||||
placeholder="Album title"
|
||||
required
|
||||
error={validationErrors.title}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Slug"
|
||||
bind:value={slug}
|
||||
placeholder="album-url-slug"
|
||||
helpText="URL-friendly version of the title"
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
label="Slug"
|
||||
bind:value={formData.slug}
|
||||
placeholder="url-friendly-name"
|
||||
required
|
||||
error={validationErrors.slug}
|
||||
disabled={isSaving || mode === 'edit'}
|
||||
/>
|
||||
|
||||
<div class="form-section">
|
||||
<GalleryUploader
|
||||
label="Album Photos"
|
||||
bind:value={gallery}
|
||||
onUpload={handleGalleryUpload}
|
||||
onReorder={handleGalleryReorder}
|
||||
required={true}
|
||||
showBrowseLibrary={true}
|
||||
maxItems={50}
|
||||
placeholder="Add photos to your album"
|
||||
helpText="First photo will be used as the album cover"
|
||||
error={gallery.length === 0 ? 'At least one photo is required' : undefined}
|
||||
/>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<Input
|
||||
label="Location"
|
||||
bind:value={formData.location}
|
||||
placeholder="e.g. Tokyo, Japan"
|
||||
error={validationErrors.location}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
<Input
|
||||
label="Year"
|
||||
type="text"
|
||||
bind:value={formData.year}
|
||||
placeholder="e.g. 2023 or 2023-2025"
|
||||
error={validationErrors.year}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<div class="editor-wrapper">
|
||||
<label class="form-label">Description</label>
|
||||
<Editor
|
||||
bind:this={editorRef}
|
||||
bind:data={content}
|
||||
onChange={handleEditorChange}
|
||||
placeholder="Write a description for your album..."
|
||||
simpleMode={false}
|
||||
minHeight={200}
|
||||
<!-- Display Settings -->
|
||||
<div class="form-section">
|
||||
<label class="toggle-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={formData.showInUniverse}
|
||||
disabled={isSaving}
|
||||
class="toggle-input"
|
||||
/>
|
||||
<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 class="form-section">
|
||||
<Input
|
||||
label="Tags"
|
||||
bind:value={tags}
|
||||
placeholder="travel, photography, nature"
|
||||
helpText="Separate tags with commas"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</AdminPage>
|
||||
|
||||
<!-- Media Modal -->
|
||||
{#if album && mode === 'edit'}
|
||||
<UnifiedMediaModal
|
||||
bind:isOpen={showBulkAlbumModal}
|
||||
albumId={album.id}
|
||||
showInAlbumMode={true}
|
||||
onSave={handleBulkAlbumSave}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
@import '$styles/variables.scss';
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
header {
|
||||
display: grid;
|
||||
grid-template-columns: 250px 1fr 250px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
gap: $unit-2x;
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: $grey-10;
|
||||
.header-left {
|
||||
width: 250px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $unit-2x;
|
||||
}
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $unit-2x;
|
||||
.header-center {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
width: 250px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: $unit-2x;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
|
|
@ -338,51 +423,226 @@
|
|||
}
|
||||
}
|
||||
|
||||
.album-form {
|
||||
max-width: 800px;
|
||||
.admin-container {
|
||||
width: 100%;
|
||||
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 {
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 8px;
|
||||
padding: $unit-2x;
|
||||
margin-bottom: $unit-3x;
|
||||
color: #dc2626;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.form-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-4x;
|
||||
background-color: #fee;
|
||||
color: #d33;
|
||||
padding: $unit-3x;
|
||||
border-radius: $unit;
|
||||
margin-bottom: $unit-4x;
|
||||
max-width: 700px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-4x;
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-bottom: $unit-6x;
|
||||
}
|
||||
}
|
||||
|
||||
.editor-wrapper {
|
||||
.form-label {
|
||||
display: block;
|
||||
.section-title {
|
||||
font-size: 1.125rem;
|
||||
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-weight: 600;
|
||||
color: $grey-20;
|
||||
margin-bottom: $unit;
|
||||
}
|
||||
|
||||
.toggle-description {
|
||||
font-size: 0.75rem;
|
||||
color: $grey-50;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
@include breakpoint('phone') {
|
||||
.album-form {
|
||||
padding: $unit-2x;
|
||||
// Button styles
|
||||
.btn-secondary {
|
||||
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 {
|
||||
flex-wrap: wrap;
|
||||
gap: $unit;
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -18,15 +18,15 @@
|
|||
date: string | null
|
||||
location: string | null
|
||||
coverPhotoId: number | null
|
||||
isPhotography: boolean
|
||||
status: string
|
||||
showInUniverse: boolean
|
||||
publishedAt: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
photos: Photo[]
|
||||
content?: any
|
||||
_count: {
|
||||
photos: number
|
||||
media: number
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -105,7 +105,7 @@
|
|||
}
|
||||
|
||||
function getPhotoCount(): number {
|
||||
return album._count?.photos || 0
|
||||
return album._count?.media || 0
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
@ -135,9 +135,10 @@
|
|||
<h3 class="album-title">{album.title}</h3>
|
||||
<AdminByline
|
||||
sections={[
|
||||
album.isPhotography ? 'Photography' : 'Album',
|
||||
'Album',
|
||||
album.status === 'published' ? 'Published' : 'Draft',
|
||||
`${getPhotoCount()} ${getPhotoCount() === 1 ? 'photo' : 'photos'}`,
|
||||
...(album.content ? ['📖 Story'] : []),
|
||||
album.status === 'published' && album.publishedAt
|
||||
? `Published ${formatRelativeTime(album.publishedAt)}`
|
||||
: `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
|
||||
location: string | null
|
||||
coverPhotoId: number | null
|
||||
isPhotography: boolean
|
||||
status: string
|
||||
showInUniverse: boolean
|
||||
publishedAt: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
photos: Photo[]
|
||||
content?: any
|
||||
_count: {
|
||||
photos: number
|
||||
media: number
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -48,14 +48,14 @@
|
|||
let activeDropdown = $state<number | null>(null)
|
||||
|
||||
// Filter state
|
||||
let photographyFilter = $state<string>('all')
|
||||
let statusFilter = $state<string>('all')
|
||||
let sortBy = $state<string>('newest')
|
||||
|
||||
// Filter options
|
||||
const filterOptions = [
|
||||
{ value: 'all', label: 'All albums' },
|
||||
{ value: 'true', label: 'Photography albums' },
|
||||
{ value: 'false', label: 'Regular albums' }
|
||||
{ value: 'published', label: 'Published' },
|
||||
{ value: 'draft', label: 'Drafts' }
|
||||
]
|
||||
|
||||
const sortOptions = [
|
||||
|
|
@ -107,11 +107,11 @@
|
|||
albums = data.albums || []
|
||||
total = data.pagination?.total || albums.length
|
||||
|
||||
// Calculate album type counts
|
||||
// Calculate album status counts
|
||||
const counts: Record<string, number> = {
|
||||
all: albums.length,
|
||||
photography: albums.filter((a) => a.isPhotography).length,
|
||||
regular: albums.filter((a) => !a.isPhotography).length
|
||||
published: albums.filter((a) => a.status === 'published').length,
|
||||
draft: albums.filter((a) => a.status === 'draft').length
|
||||
}
|
||||
albumTypeCounts = counts
|
||||
|
||||
|
|
@ -129,10 +129,10 @@
|
|||
let filtered = [...albums]
|
||||
|
||||
// Apply filter
|
||||
if (photographyFilter === 'true') {
|
||||
filtered = filtered.filter((album) => album.isPhotography === true)
|
||||
} else if (photographyFilter === 'false') {
|
||||
filtered = filtered.filter((album) => album.isPhotography === false)
|
||||
if (statusFilter === 'published') {
|
||||
filtered = filtered.filter((album) => album.status === 'published')
|
||||
} else if (statusFilter === 'draft') {
|
||||
filtered = filtered.filter((album) => album.status === 'draft')
|
||||
}
|
||||
|
||||
// Apply sorting
|
||||
|
|
@ -289,7 +289,7 @@
|
|||
<AdminFilters>
|
||||
{#snippet left()}
|
||||
<Select
|
||||
bind:value={photographyFilter}
|
||||
bind:value={statusFilter}
|
||||
options={filterOptions}
|
||||
size="small"
|
||||
variant="minimal"
|
||||
|
|
@ -316,7 +316,7 @@
|
|||
{:else if filteredAlbums.length === 0}
|
||||
<div class="empty-state">
|
||||
<p>
|
||||
{#if photographyFilter === 'all'}
|
||||
{#if statusFilter === 'all'}
|
||||
No albums found. Create your first album!
|
||||
{:else}
|
||||
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">
|
||||
import { goto } from '$app/navigation'
|
||||
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)
|
||||
import AlbumForm from '$lib/components/admin/AlbumForm.svelte'
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>New Album - Admin @jedmund</title>
|
||||
</svelte:head>
|
||||
|
||||
<AdminPage>
|
||||
<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>
|
||||
<AlbumForm mode="create" />
|
||||
|
|
|
|||
Loading…
Reference in a new issue