- 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>
441 lines
9.3 KiB
Svelte
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>
|