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">
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: [] }
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' }] }
}
// Load gallery from initialData if provided directly
if (initialData.gallery) {
gallery = initialData.gallery
isLoading = false
}
}
})
async function loadGalleryMedia(mediaIds: number[]) {
async function loadAlbumMedia() {
if (!album) return
try {
const auth = localStorage.getItem('admin_auth')
if (!auth) return
const mediaPromises = mediaIds.map(async (id) => {
const response = await fetch(`/api/media/${id}`, {
headers: { Authorization: `Basic ${auth}` }
})
const response = await authenticatedFetch(`/api/albums/${album.id}`)
if (response.ok) {
return await response.json()
const data = await response.json()
albumMedia = data.media || []
}
return null
})
const mediaResults = await Promise.all(mediaPromises)
gallery = mediaResults.filter((media) => media !== null)
} catch (error) {
console.error('Failed to load gallery media:', error)
} catch (err) {
console.error('Failed to load album media:', err)
}
}
// 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 = ''
function validateForm() {
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()) : []
albumSchema.parse({
title: formData.title,
slug: formData.slug,
location: formData.location || undefined,
year: formData.year || undefined
})
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
}
}
const url = mode === 'edit' ? `/api/posts/${postId}` : '/api/posts'
const method = mode === 'edit' ? 'PUT' : 'POST'
const auth = localStorage.getItem('admin_auth')
if (!auth) {
goto('/admin/login')
async function handleSave() {
if (!validateForm()) {
error = 'Please fix the validation errors'
return
}
const response = await fetch(url, {
try {
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/albums/${album?.id}` : '/api/albums'
const method = mode === 'edit' ? 'PUT' : 'POST'
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,122 +221,188 @@
/>
</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">
<div class="admin-container">
{#if isLoading}
<div class="loading">Loading album...</div>
{:else}
{#if error}
<div class="error-message">
{error}
</div>
<div class="error-message">{error}</div>
{/if}
<div class="form-content">
<div class="tab-panels">
<!-- Metadata Panel -->
<div class="panel content-wrapper" class:active={activeTab === 'metadata'}>
<!-- Album Details -->
<div class="form-section">
<Input
label="Album Title"
label="Title"
size="jumbo"
bind:value={title}
placeholder="Enter album title"
required={true}
error={title.trim().length === 0 ? 'Title is required' : undefined}
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"
bind:value={formData.slug}
placeholder="url-friendly-name"
required
error={validationErrors.slug}
disabled={isSaving || mode === 'edit'}
/>
</div>
<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-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}
/>
</div>
</div>
<div class="form-section">
<div class="form-grid">
<Input
label="Tags"
bind:value={tags}
placeholder="travel, photography, nature"
helpText="Separate tags with commas"
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>
<!-- 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>
{/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 {
display: grid;
grid-template-columns: 250px 1fr 250px;
align-items: center;
width: 100%;
gap: $unit-2x;
.header-left {
width: 250px;
display: flex;
align-items: center;
gap: $unit-2x;
h1 {
font-size: 1.5rem;
font-weight: 700;
margin: 0;
color: $grey-10;
}
.header-center {
display: flex;
justify-content: center;
align-items: center;
}
.header-actions {
width: 250px;
display: flex;
align-items: center;
justify-content: flex-end;
gap: $unit-2x;
}
}
.btn-icon {
width: 40px;
@ -338,51 +423,226 @@
}
}
.album-form {
max-width: 800px;
.admin-container {
width: 100%;
margin: 0 auto;
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;
font-size: 0.875rem;
.section-title {
font-size: 1.125rem;
font-weight: 600;
color: $grey-20;
margin-bottom: $unit;
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') {
.album-form {
padding: $unit-2x;
grid-template-columns: 1fr;
}
}
.header-actions {
flex-wrap: wrap;
gap: $unit;
.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;
}
.toggle-description {
font-size: 0.75rem;
color: $grey-50;
line-height: 1.4;
}
}
// 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;
}
&: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>

View file

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

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

View file

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