jedmund-svelte/src/lib/components/admin/AlbumForm.svelte
Devin AI 841ee79885 lint: remove more unused imports and variables (5 fixes)
Co-Authored-By: Justin Edmund <justin@jedmund.com>
2025-11-23 14:32:37 +00:00

558 lines
12 KiB
Svelte

<script lang="ts">
import { z } from 'zod'
import AdminPage from './AdminPage.svelte'
import AdminSegmentedControl from './AdminSegmentedControl.svelte'
import Input from './Input.svelte'
import DropdownSelectField from './DropdownSelectField.svelte'
import AutoSaveStatus from './AutoSaveStatus.svelte'
import UnifiedMediaModal from './UnifiedMediaModal.svelte'
import SmartImage from '../SmartImage.svelte'
import Composer from './composer'
import type { Album, Media } from '@prisma/client'
import type { JSONContent } from '@tiptap/core'
interface Props {
album?: Album | null
mode: 'create' | 'edit'
}
let { album = null, mode }: Props = $props()
// State
let isLoading = $state(mode === 'edit')
let showBulkAlbumModal = $state(false)
let albumMedia = $state<Array<{ media: Media; displayOrder: number }>>([])
let editorInstance = $state<{ save: () => Promise<JSONContent>; clear: () => void } | undefined>()
let activeTab = $state('metadata')
let pendingMediaIds = $state<number[]>([]) // Photos to add after album creation
const tabOptions = [
{ value: 'metadata', label: 'Metadata' },
{ value: 'content', label: 'Content' }
]
const statusOptions = [
{
value: 'draft',
label: 'Draft',
description: 'Only visible to you'
},
{
value: 'published',
label: 'Published',
description: 'Visible on your public site'
}
]
// Form data
let formData = $state({
title: '',
slug: '',
year: '',
location: '',
showInUniverse: false,
status: 'draft' as 'draft' | 'published',
content: { type: 'doc', content: [{ type: 'paragraph' }] } as JSONContent
})
// Derived state for existing media IDs
const existingMediaIds = $derived(albumMedia.map((item) => item.media.id))
// Watch for album changes and populate form data
$effect(() => {
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, '')
}
})
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' }] }
}
isLoading = false
}
async function loadAlbumMedia() {
if (!album) return
try {
const response = await fetch(`/api/albums/${album.id}`, {
credentials: 'same-origin'
})
if (response.ok) {
const data = await response.json()
albumMedia = data.media || []
}
} catch (err) {
console.error('Failed to load album media:', err)
}
}
async function handleBulkAlbumSave() {
// Reload album to get updated photo count
if (album && mode === 'edit') {
await loadAlbumMedia()
}
}
function handleContentUpdate(content: JSONContent) {
formData.content = content
}
function handlePhotoSelection(media: Media[]) {
pendingMediaIds = media.map((m) => m.id)
}
</script>
<AdminPage>
<header slot="header">
<div class="header-left">
<h1 class="form-title">{formData.title || 'Untitled Album'}</h1>
</div>
<div class="header-center">
<AdminSegmentedControl
options={tabOptions}
value={activeTab}
onChange={(value) => (activeTab = value)}
/>
</div>
<div class="header-actions">
{#if !isLoading}
<AutoSaveStatus
status="idle"
lastSavedAt={album?.updatedAt}
/>
{/if}
</div>
</header>
<div class="admin-container">
{#if isLoading}
<div class="loading">Loading album...</div>
{:else}
<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
/>
<Input
label="Slug"
bind:value={formData.slug}
placeholder="url-friendly-name"
required
disabled={mode === 'edit'}
/>
<div class="form-grid">
<Input
label="Location"
bind:value={formData.location}
placeholder="e.g. Tokyo, Japan"
/>
<Input
label="Year"
type="text"
bind:value={formData.year}
placeholder="e.g. 2023 or 2023-2025"
/>
</div>
<DropdownSelectField
label="Status"
bind:value={formData.status}
options={statusOptions}
/>
</div>
<!-- Display Settings -->
<div class="form-section">
<label class="toggle-label">
<input
type="checkbox"
bind:checked={formData.showInUniverse}
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 -->
<div class="form-section">
<div class="section-header">
<h3 class="section-title">
Photos {albumMedia.length > 0 || pendingMediaIds.length > 0
? `(${mode === 'edit' ? albumMedia.length : pendingMediaIds.length})`
: ''}
</h3>
<button class="btn-secondary" onclick={() => (showBulkAlbumModal = true)}>
{mode === 'create' ? 'Select Photos' : 'Manage Photos'}
</button>
</div>
{#if mode === 'edit' && 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 if mode === 'create' && pendingMediaIds.length > 0}
<p class="selected-count">
{pendingMediaIds.length} photo{pendingMediaIds.length !== 1 ? 's' : ''} selected. They
will be added when you save the album.
</p>
{:else}
<p class="empty-state">
No photos {mode === 'create' ? 'selected' : 'added'} yet. Click "{mode === 'create'
? 'Select Photos'
: 'Manage Photos'}" to {mode === 'create' ? 'select' : 'add'} photos.
</p>
{/if}
</div>
</div>
<!-- Content Panel -->
<div class="panel panel-content" class:active={activeTab === 'content'}>
<Composer
bind:this={editorInstance}
bind:data={formData.content}
placeholder="Add album content..."
onChange={handleContentUpdate}
albumId={album?.id}
variant="full"
/>
</div>
</div>
{/if}
</div>
</AdminPage>
<!-- Media Modal -->
<UnifiedMediaModal
bind:isOpen={showBulkAlbumModal}
albumId={album?.id}
selectedIds={mode === 'edit' ? existingMediaIds : pendingMediaIds}
showInAlbumMode={mode === 'edit'}
onSave={mode === 'edit' ? handleBulkAlbumSave : undefined}
onSelect={mode === 'create' ? handlePhotoSelection : undefined}
mode="multiple"
title={mode === 'create' ? 'Select Photos for Album' : 'Manage Album Photos'}
confirmText={mode === 'create' ? 'Select Photos' : 'Update Photos'}
/>
<style lang="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;
}
.header-center {
display: flex;
justify-content: center;
align-items: center;
}
.header-actions {
width: 250px;
display: flex;
justify-content: flex-end;
gap: $unit-2x;
}
}
.form-title {
margin: 0;
font-size: 1rem;
font-weight: 500;
color: $gray-20;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.btn-icon {
width: 40px;
height: 40px;
border: none;
background: none;
color: $gray-40;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
transition: all 0.2s ease;
&:hover {
background: $gray-90;
color: $gray-10;
}
}
.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: $gray-40;
}
.form-section {
display: flex;
flex-direction: column;
gap: $unit-4x;
&:not(:last-child) {
margin-bottom: $unit-6x;
}
}
.section-title {
font-size: 1.125rem;
font-weight: 600;
color: $gray-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: $gray-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: $gray-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: $gray-10;
font-size: 0.875rem;
}
.toggle-description {
font-size: 0.75rem;
color: $gray-50;
line-height: 1.4;
}
}
// Button styles
.btn-secondary {
padding: $unit $unit-2x;
border: 1px solid $gray-80;
background: white;
color: $gray-20;
border-radius: 8px;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: $gray-95;
border-color: $gray-70;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.empty-state {
color: $gray-50;
font-size: 0.875rem;
text-align: center;
padding: $unit-4x;
background: $gray-95;
border-radius: $unit;
margin: 0;
}
.selected-count {
color: $gray-30;
font-size: 0.875rem;
padding: $unit-2x;
margin: 0;
background: $gray-95;
border-radius: $unit;
border: 1px solid $gray-90;
}
</style>