jedmund-svelte/src/lib/components/admin/AlbumSelector.svelte
Justin Edmund a31291d69f refactor: replace deprecated $grey- variables with $gray-
- Replace 802 instances of $grey- variables with $gray- across 106 files
- Remove legacy color aliases from variables.scss
- Maintain consistent naming convention throughout codebase

This completes the migration to the new color scale system.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-25 21:41:50 -04:00

441 lines
9.3 KiB
Svelte

<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 $gray-85;
h3 {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: $gray-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: $gray-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: $gray-95;
border-radius: $unit;
cursor: pointer;
transition: background 0.2s ease;
&:hover {
background: $gray-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: $gray-10;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.album-meta {
font-size: 0.75rem;
color: $gray-40;
}
.create-new-form {
display: flex;
flex-direction: column;
gap: $unit-3x;
h4 {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: $gray-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 $gray-85;
}
</style>