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:
Justin Edmund 2025-06-24 01:12:54 +01:00
parent 8627b1d574
commit e488107544
6 changed files with 1001 additions and 1934 deletions

View file

@ -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>

View file

@ -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)}`

View 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>

View file

@ -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

View file

@ -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>