feat(admin): update admin components with improved UI and icons

- Add album and media icons for better navigation
- Update AdminNavBar with new routes and improved styling
- Enhance GalleryUploader with better drag-and-drop support
- Improve ImagePicker and ImageUploader components
- Remove unused ImageUploadPlaceholder component
- Update MediaDetailsModal with album association features
- Improve Modal component styling and animations
- Add PostDropdown for post management actions

Modernizes the admin interface with better usability.

🤖 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:14:08 +01:00
parent 78663151a8
commit 0d4bf6d53f
10 changed files with 311 additions and 488 deletions

View file

@ -0,0 +1,3 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M2 3C2 2.44772 2.44772 2 3 2H15C16.6569 2 18 3.34315 18 5V15C18 16.6569 16.6569 18 15 18H3C2.44772 18 2 17.5523 2 17V3ZM7 5C6.44772 5 6 5.44772 6 6V10C6 10.5523 6.44772 11 7 11H13C13.5523 11 14 10.5523 14 10V6C14 5.44772 13.5523 5 13 5H7ZM6 14C6 13.4477 6.44772 13 7 13H13C13.5523 13 14 13.4477 14 14C14 14.5523 13.5523 15 13 15H7C6.44772 15 6 14.5523 6 14Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 534 B

View file

@ -0,0 +1,4 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13 18C15.7614 18 18 15.7614 18 13C18 10.2386 15.7614 8 13 8C10.2386 8 8 10.2386 8 13C8 15.7614 10.2386 18 13 18Z" />
<path d="M10.5 2C11.3284 2 12 2.67157 12 3.5V6.07227C8.93446 6.51084 6.51084 8.93446 6.07227 12H3.5C2.67157 12 2 11.3284 2 10.5V3.5C2 2.67157 2.67157 2 3.5 2H10.5Z" />
</svg>

After

Width:  |  Height:  |  Size: 398 B

View file

@ -4,7 +4,8 @@
import AvatarSimple from '$lib/components/AvatarSimple.svelte'
import WorkIcon from '$icons/work.svg?component'
import UniverseIcon from '$icons/universe.svg?component'
import PhotosIcon from '$icons/photos.svg?component'
import MediaIcon from '$icons/media.svg?component'
import AlbumIcon from '$icons/album.svg?component'
const currentPath = $derived($page.url.pathname)
let isScrolled = $state(false)
@ -31,8 +32,8 @@
const navItems: NavItem[] = [
{ text: 'Projects', href: '/admin/projects', icon: WorkIcon },
{ text: 'Universe', href: '/admin/posts', icon: UniverseIcon },
{ text: 'Albums', href: '/admin/albums', icon: PhotosIcon },
{ text: 'Media', href: '/admin/media', icon: PhotosIcon }
{ text: 'Albums', href: '/admin/albums', icon: AlbumIcon },
{ text: 'Media', href: '/admin/media', icon: MediaIcon }
]
// Calculate active index based on current path

View file

@ -3,7 +3,7 @@
import Button from './Button.svelte'
import Input from './Input.svelte'
import SmartImage from '../SmartImage.svelte'
import MediaLibraryModal from './MediaLibraryModal.svelte'
import UnifiedMediaModal from './UnifiedMediaModal.svelte'
import MediaDetailsModal from './MediaDetailsModal.svelte'
import { authenticatedFetch } from '$lib/admin-auth'
@ -369,7 +369,7 @@
thumbnailUrl: media.thumbnailUrl,
width: media.width,
height: media.height,
altText: media.altText || '',
// altText removed - using description only
description: media.description || '',
isPhotography: media.isPhotography || false,
createdAt: media.createdAt,
@ -387,7 +387,7 @@
if (index !== -1) {
value[index] = {
...value[index],
altText: updatedMedia.altText,
// altText removed - using description only
description: updatedMedia.description,
isPhotography: updatedMedia.isPhotography,
updatedAt: updatedMedia.updatedAt
@ -587,13 +587,13 @@
thumbnailUrl: media.thumbnailUrl,
width: media.width,
height: media.height,
altText: media.altText,
// altText removed - using description only
description: media.description,
isPhotography: media.isPhotography || false,
createdAt: media.createdAt,
updatedAt: media.updatedAt
}}
alt={media.altText || media.filename || 'Gallery image'}
alt={media.description || media.filename || 'Gallery image'}
containerWidth={300}
loading="lazy"
aspectRatio="1:1"
@ -675,7 +675,7 @@
</div>
<!-- Media Library Modal -->
<MediaLibraryModal
<UnifiedMediaModal
bind:isOpen={isMediaLibraryOpen}
mode="multiple"
fileType="image"

View file

@ -1,6 +1,6 @@
<script lang="ts">
import Button from './Button.svelte'
import MediaLibraryModal from './MediaLibraryModal.svelte'
import UnifiedMediaModal from './UnifiedMediaModal.svelte'
import type { Media } from '@prisma/client'
interface Props {
@ -212,14 +212,15 @@
{/if}
<!-- Media Library Modal -->
<MediaLibraryModal
<UnifiedMediaModal
bind:isOpen={showModal}
mode="single"
fileType="image"
{selectedIds}
title="Select Image"
confirmText="Select Image"
onselect={handleImageSelect}
onSelect={handleImageSelect}
onClose={() => (showModal = false)}
/>
</div>

View file

@ -1,296 +0,0 @@
<script lang="ts">
import type { NodeViewProps } from '@tiptap/core'
import type { Media } from '@prisma/client'
import Image from 'lucide-svelte/icons/image'
import Upload from 'lucide-svelte/icons/upload'
import Link from 'lucide-svelte/icons/link'
import Grid from 'lucide-svelte/icons/grid-3x3'
import { NodeViewWrapper } from 'svelte-tiptap'
import MediaLibraryModal from './MediaLibraryModal.svelte'
const { editor, deleteNode }: NodeViewProps = $props()
let fileInput: HTMLInputElement
let isDragging = $state(false)
let isMediaLibraryOpen = $state(false)
let isUploading = $state(false)
function handleBrowseLibrary(e: MouseEvent) {
if (!editor.isEditable) return
e.preventDefault()
isMediaLibraryOpen = true
}
function handleDirectUpload(e: MouseEvent) {
if (!editor.isEditable) return
e.preventDefault()
fileInput.click()
}
function handleMediaSelect(media: Media | Media[]) {
const selectedMedia = Array.isArray(media) ? media[0] : media
if (selectedMedia) {
// Set a reasonable default width (max 600px)
const displayWidth =
selectedMedia.width && selectedMedia.width > 600 ? 600 : selectedMedia.width
editor
.chain()
.focus()
.setImage({
src: selectedMedia.url,
alt: selectedMedia.altText || '',
width: displayWidth,
height: selectedMedia.height,
align: 'center'
})
.run()
}
isMediaLibraryOpen = false
}
function handleMediaLibraryClose() {
isMediaLibraryOpen = false
}
async function handleFileSelect(e: Event) {
const target = e.target as HTMLInputElement
const file = target.files?.[0]
if (file) {
await uploadFile(file)
}
// Reset input
target.value = ''
}
async function uploadFile(file: File) {
// Check file type
if (!file.type.startsWith('image/')) {
alert('Please select an image file')
return
}
// Check file size (2MB max)
const filesize = file.size / 1024 / 1024
if (filesize > 2) {
alert(`Image too large! File size: ${filesize.toFixed(2)} MB (max 2MB)`)
return
}
isUploading = true
try {
const auth = localStorage.getItem('admin_auth')
if (!auth) {
throw new Error('Not authenticated')
}
const formData = new FormData()
formData.append('file', file)
formData.append('type', 'image')
const response = await fetch('/api/media/upload', {
method: 'POST',
headers: {
Authorization: `Basic ${auth}`
},
body: formData
})
if (!response.ok) {
throw new Error('Upload failed')
}
const media = await response.json()
// Insert the uploaded image with reasonable default width
const displayWidth = media.width && media.width > 600 ? 600 : media.width
editor
.chain()
.focus()
.setImage({
src: media.url,
alt: media.altText || '',
width: displayWidth,
height: media.height,
align: 'center'
})
.run()
} catch (error) {
console.error('Image upload failed:', error)
alert('Failed to upload image. Please try again.')
} finally {
isUploading = false
}
}
// Drag and drop handlers
function handleDragOver(e: DragEvent) {
e.preventDefault()
e.stopPropagation()
isDragging = true
}
function handleDragLeave(e: DragEvent) {
e.preventDefault()
e.stopPropagation()
isDragging = false
}
async function handleDrop(e: DragEvent) {
e.preventDefault()
e.stopPropagation()
isDragging = false
const file = e.dataTransfer?.files[0]
if (file && file.type.startsWith('image/')) {
await uploadFile(file)
}
}
// Handle keyboard navigation
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
handleBrowseLibrary(e as any)
} else if (e.key === 'Escape') {
deleteNode()
}
}
</script>
<NodeViewWrapper class="edra-media-placeholder-wrapper" contenteditable="false">
<input
bind:this={fileInput}
type="file"
accept="image/*"
onchange={handleFileSelect}
style="display: none;"
/>
<div class="edra-image-placeholder-container">
{#if isUploading}
<div class="edra-image-placeholder-uploading">
<div class="spinner"></div>
<span>Uploading image...</span>
</div>
{:else}
<button
class="edra-image-placeholder-option"
onclick={handleDirectUpload}
onkeydown={handleKeyDown}
tabindex="0"
aria-label="Upload Image"
title="Upload from device"
>
<Upload class="edra-image-placeholder-icon" />
<span class="edra-image-placeholder-text">Upload Image</span>
</button>
<button
class="edra-image-placeholder-option"
onclick={handleBrowseLibrary}
onkeydown={handleKeyDown}
tabindex="0"
aria-label="Browse Media Library"
title="Choose from library"
>
<Grid class="edra-image-placeholder-icon" />
<span class="edra-image-placeholder-text">Browse Library</span>
</button>
{/if}
</div>
<!-- Media Library Modal -->
<MediaLibraryModal
bind:isOpen={isMediaLibraryOpen}
mode="single"
fileType="image"
onSelect={handleMediaSelect}
onClose={handleMediaLibraryClose}
/>
</NodeViewWrapper>
<style>
.edra-image-placeholder-container {
display: flex;
gap: 12px;
padding: 24px;
border: 2px dashed #e5e7eb;
border-radius: 8px;
background: #f9fafb;
transition: all 0.2s ease;
justify-content: center;
align-items: center;
}
.edra-image-placeholder-container:hover {
border-color: #d1d5db;
background: #f3f4f6;
}
.edra-image-placeholder-option {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 16px 20px;
border: 1px solid #e5e7eb;
border-radius: 6px;
background: white;
cursor: pointer;
transition: all 0.2s ease;
min-width: 140px;
}
.edra-image-placeholder-option:hover {
border-color: #d1d5db;
background: #f9fafb;
transform: translateY(-1px);
}
.edra-image-placeholder-option:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.edra-image-placeholder-uploading {
display: flex;
align-items: center;
gap: 8px;
padding: 20px;
color: #6b7280;
}
.spinner {
width: 16px;
height: 16px;
border: 2px solid #f3f4f6;
border-top: 2px solid #3b82f6;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
:global(.edra-image-placeholder-icon) {
width: 28px;
height: 28px;
color: #6b7280;
}
.edra-image-placeholder-text {
font-size: 14px;
color: #6b7280;
font-weight: 500;
}
</style>

View file

@ -3,7 +3,7 @@
import Button from './Button.svelte'
import Input from './Input.svelte'
import SmartImage from '../SmartImage.svelte'
import MediaLibraryModal from './MediaLibraryModal.svelte'
import UnifiedMediaModal from './UnifiedMediaModal.svelte'
import { authenticatedFetch } from '$lib/admin-auth'
import RefreshIcon from '$icons/refresh.svg?component'
@ -15,7 +15,7 @@
aspectRatio?: string // e.g., "16:9", "1:1"
required?: boolean
error?: string
allowAltText?: boolean
allowAltText?: boolean // @deprecated - Now using description field for alt text
maxFileSize?: number // MB limit
placeholder?: string
helpText?: string
@ -45,7 +45,7 @@
let uploadError = $state<string | null>(null)
let isDragOver = $state(false)
let fileInputElement: HTMLInputElement
let altTextValue = $state(value?.altText || '')
// Removed altText - using only description field
let descriptionValue = $state(value?.description || '')
let isMediaLibraryOpen = $state(false)
@ -79,11 +79,9 @@
const formData = new FormData()
formData.append('file', file)
if (allowAltText && altTextValue.trim()) {
formData.append('altText', altTextValue.trim())
}
// Removed altText upload - description is handled separately
if (allowAltText && descriptionValue.trim()) {
if (descriptionValue.trim()) {
formData.append('description', descriptionValue.trim())
}
@ -132,7 +130,7 @@
// Brief delay to show completion
setTimeout(() => {
value = uploadedMedia
altTextValue = uploadedMedia.altText || ''
// altText removed - using description only
descriptionValue = uploadedMedia.description || ''
onUpload(uploadedMedia)
isUploading = false
@ -181,35 +179,13 @@
// Remove uploaded image
function handleRemove() {
value = null
altTextValue = ''
// altText removed
descriptionValue = ''
uploadError = null
onRemove?.()
}
// Update alt text on server
async function handleAltTextChange() {
if (!value) return
try {
const response = await authenticatedFetch(`/api/media/${value.id}/metadata`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
altText: altTextValue.trim() || null
})
})
if (response.ok) {
const updatedData = await response.json()
value = { ...value, altText: updatedData.altText, updatedAt: updatedData.updatedAt }
}
} catch (error) {
console.error('Failed to update alt text:', error)
}
}
// Removed handleAltTextChange - using only description
async function handleDescriptionChange() {
if (!value) return
@ -243,7 +219,7 @@
// Since this is single mode, selectedMedia will be a single Media object
const media = selectedMedia as Media
value = media
altTextValue = media.altText || ''
// altText removed - using description only
descriptionValue = media.description || ''
onUpload(media)
}
@ -275,7 +251,7 @@
<div class="compact-image">
<SmartImage
media={value}
alt={value?.altText || value?.filename || 'Uploaded image'}
alt={value?.description || value?.filename || 'Uploaded image'}
containerWidth={100}
loading="eager"
{aspectRatio}
@ -319,29 +295,18 @@
</div>
<div class="compact-info">
<!-- Alt Text Input in compact mode -->
{#if allowAltText}
<div class="compact-metadata">
<Input
type="text"
label="Alt Text"
bind:value={altTextValue}
placeholder="Describe this image for screen readers"
buttonSize="small"
onblur={handleAltTextChange}
/>
<Input
type="textarea"
label="Description (Optional)"
bind:value={descriptionValue}
placeholder="Additional description or caption"
rows={2}
buttonSize="small"
onblur={handleDescriptionChange}
/>
</div>
{/if}
<!-- Description Input in compact mode -->
<div class="compact-metadata">
<Input
type="textarea"
label="Description"
bind:value={descriptionValue}
placeholder="Describe this image for accessibility and SEO"
rows={2}
buttonSize="small"
onblur={handleDescriptionChange}
/>
</div>
</div>
</div>
{:else}
@ -520,24 +485,16 @@
</div>
{/if}
<!-- Alt Text Input (only in standard mode, compact mode has it inline) -->
{#if allowAltText && hasValue && !compact}
<!-- Description Input (only in standard mode, compact mode has it inline) -->
{#if hasValue && !compact}
<div class="metadata-section">
<Input
type="text"
label="Alt Text"
bind:value={altTextValue}
placeholder="Describe this image for screen readers"
helpText="Help make your content accessible. Describe what's in the image."
onblur={handleAltTextChange}
/>
<Input
type="textarea"
label="Description (Optional)"
label="Description"
bind:value={descriptionValue}
placeholder="Additional description or caption"
rows={2}
placeholder="Describe this image for accessibility and SEO"
helpText="This description will be used for alt text and can also serve as a caption."
rows={3}
onblur={handleDescriptionChange}
/>
</div>
@ -559,7 +516,7 @@
</div>
<!-- Media Library Modal -->
<MediaLibraryModal
<UnifiedMediaModal
bind:isOpen={isMediaLibraryOpen}
mode="single"
fileType="image"

View file

@ -4,6 +4,8 @@
import Input from './Input.svelte'
import Textarea from './Textarea.svelte'
import SmartImage from '../SmartImage.svelte'
import AlbumSelector from './AlbumSelector.svelte'
import AlbumIcon from '$icons/album.svg?component'
import { authenticatedFetch } from '$lib/admin-auth'
import type { Media } from '@prisma/client'
@ -36,19 +38,27 @@
>([])
let loadingUsage = $state(false)
// Album management state
let albums = $state<Array<{ id: number; title: string; slug: string }>>([])
let loadingAlbums = $state(false)
let showAlbumSelector = $state(false)
// EXIF toggle state
let showExif = $state(false)
// Initialize form when media changes
$effect(() => {
if (media) {
// Use description if available, otherwise fall back to altText for backwards compatibility
description = media.description || media.altText || ''
description = media.description || ''
isPhotography = media.isPhotography || false
error = ''
successMessage = ''
showExif = false
loadUsage()
// Only load albums for images
if (media.mimeType?.startsWith('image/')) {
loadAlbums()
}
}
})
@ -75,6 +85,27 @@
}
}
// Load albums the media belongs to
async function loadAlbums() {
if (!media) return
try {
loadingAlbums = true
// Load albums this media belongs to
const mediaResponse = await authenticatedFetch(`/api/media/${media.id}/albums`)
if (mediaResponse.ok) {
const data = await mediaResponse.json()
albums = data.albums || []
}
} catch (error) {
console.error('Error loading albums:', error)
albums = []
} finally {
loadingAlbums = false
}
}
function handleClose() {
description = ''
isPhotography = false
@ -97,8 +128,6 @@
'Content-Type': 'application/json'
},
body: JSON.stringify({
// Use description for both altText and description fields
altText: description.trim() || null,
description: description.trim() || null,
isPhotography: isPhotography
})
@ -205,11 +234,7 @@
<div class="image-pane">
{#if media.mimeType.startsWith('image/')}
<div class="image-container">
<SmartImage
{media}
alt={media.description || media.altText || media.filename}
class="preview-image"
/>
<SmartImage {media} alt={media.description || media.filename} class="preview-image" />
</div>
{:else}
<div class="file-placeholder">
@ -303,103 +328,109 @@
<span class="label">Size</span>
<span class="value">{formatFileSize(media.size)}</span>
</div>
{#if media.width && media.height}
<div class="info-item">
<span class="label">Dimensions</span>
<span class="value">{media.width} × {media.height}px</span>
</div>
{/if}
{#if media.dominantColor}
<div class="info-item">
<span class="label">Dominant Color</span>
<span class="value color-value">
<span
class="color-swatch"
style="background-color: {media.dominantColor}"
title={media.dominantColor}
></span>
{media.dominantColor}
</span>
</div>
{:else}
<!-- Debug: dominantColor = {JSON.stringify(media.dominantColor)} -->
{/if}
<div class="info-item">
<span class="label">Uploaded</span>
<span class="value">{new Date(media.createdAt).toLocaleDateString()}</span>
</div>
</div>
{#if media.exifData && Object.keys(media.exifData).length > 0}
{#if showExif}
<div class="exif-data">
{#if media.exifData.camera}
{#if showExif}
<div class="details-data">
<!-- Media metadata -->
<div class="media-metadata">
{#if media.width && media.height}
<div class="info-item">
<span class="label">Camera</span>
<span class="value">{media.exifData.camera}</span>
<span class="label">Dimensions</span>
<span class="value">{media.width} × {media.height}px</span>
</div>
{/if}
{#if media.exifData.lens}
{#if media.dominantColor}
<div class="info-item">
<span class="label">Lens</span>
<span class="value">{media.exifData.lens}</span>
</div>
{/if}
{#if media.exifData.focalLength}
<div class="info-item">
<span class="label">Focal Length</span>
<span class="value">{media.exifData.focalLength}</span>
</div>
{/if}
{#if media.exifData.aperture}
<div class="info-item">
<span class="label">Aperture</span>
<span class="value">{media.exifData.aperture}</span>
</div>
{/if}
{#if media.exifData.shutterSpeed}
<div class="info-item">
<span class="label">Shutter Speed</span>
<span class="value">{media.exifData.shutterSpeed}</span>
</div>
{/if}
{#if media.exifData.iso}
<div class="info-item">
<span class="label">ISO</span>
<span class="value">{media.exifData.iso}</span>
</div>
{/if}
{#if media.exifData.dateTaken}
<div class="info-item">
<span class="label">Date Taken</span>
<span class="value"
>{new Date(media.exifData.dateTaken).toLocaleDateString()}</span
>
</div>
{/if}
{#if media.exifData.coordinates}
<div class="info-item">
<span class="label">GPS</span>
<span class="value">
{media.exifData.coordinates.latitude.toFixed(6)},
{media.exifData.coordinates.longitude.toFixed(6)}
<span class="label">Dominant Color</span>
<span class="value color-value">
<span
class="color-swatch"
style="background-color: {media.dominantColor}"
title={media.dominantColor}
></span>
{media.dominantColor}
</span>
</div>
{/if}
<div class="info-item">
<span class="label">Uploaded</span>
<span class="value">{new Date(media.createdAt).toLocaleDateString()}</span>
</div>
</div>
{/if}
<Button
variant="ghost"
onclick={() => (showExif = !showExif)}
buttonSize="small"
fullWidth
pill={false}
class="exif-toggle"
>
{showExif ? 'Hide EXIF' : 'Show EXIF'}
</Button>
<!-- EXIF metadata -->
{#if media.exifData && Object.keys(media.exifData).length > 0}
<div class="metadata-divider"></div>
<div class="exif-metadata">
{#if media.exifData.camera}
<div class="info-item">
<span class="label">Camera</span>
<span class="value">{media.exifData.camera}</span>
</div>
{/if}
{#if media.exifData.lens}
<div class="info-item">
<span class="label">Lens</span>
<span class="value">{media.exifData.lens}</span>
</div>
{/if}
{#if media.exifData.focalLength}
<div class="info-item">
<span class="label">Focal Length</span>
<span class="value">{media.exifData.focalLength}</span>
</div>
{/if}
{#if media.exifData.aperture}
<div class="info-item">
<span class="label">Aperture</span>
<span class="value">{media.exifData.aperture}</span>
</div>
{/if}
{#if media.exifData.shutterSpeed}
<div class="info-item">
<span class="label">Shutter Speed</span>
<span class="value">{media.exifData.shutterSpeed}</span>
</div>
{/if}
{#if media.exifData.iso}
<div class="info-item">
<span class="label">ISO</span>
<span class="value">{media.exifData.iso}</span>
</div>
{/if}
{#if media.exifData.dateTaken}
<div class="info-item">
<span class="label">Date Taken</span>
<span class="value"
>{new Date(media.exifData.dateTaken).toLocaleDateString()}</span
>
</div>
{/if}
{#if media.exifData.coordinates}
<div class="info-item">
<span class="label">GPS</span>
<span class="value">
{media.exifData.coordinates.latitude.toFixed(6)},
{media.exifData.coordinates.longitude.toFixed(6)}
</span>
</div>
{/if}
</div>
{/if}
</div>
{/if}
<Button
variant="ghost"
onclick={() => (showExif = !showExif)}
buttonSize="small"
fullWidth
pill={false}
class="exif-toggle"
>
{showExif ? 'Hide Details' : 'Show Details'}
</Button>
</div>
<div class="pane-body-content">
@ -433,7 +464,19 @@
<!-- Usage Tracking -->
<div class="usage-section">
<h4>Used In</h4>
<div class="section-header">
<h4>Used In</h4>
{#if media.mimeType?.startsWith('image/')}
<button
class="add-album-button"
onclick={() => (showAlbumSelector = true)}
title="Manage albums"
>
<AlbumIcon />
<span>Albums</span>
</button>
{/if}
</div>
{#if loadingUsage}
<div class="usage-loading">
<div class="spinner"></div>
@ -473,6 +516,20 @@
<p class="no-usage">This media file is not currently used in any content.</p>
{/if}
</div>
<!-- Albums list -->
{#if albums.length > 0}
<div class="albums-inline">
<h4>Albums</h4>
<div class="album-tags">
{#each albums as album}
<a href="/admin/albums/{album.id}/edit" class="album-tag">
{album.title}
</a>
{/each}
</div>
</div>
{/if}
</div>
</div>
</div>
@ -506,6 +563,21 @@
</div>
</div>
</Modal>
<!-- Album Selector Modal -->
{#if showAlbumSelector && media}
<Modal isOpen={showAlbumSelector} onClose={() => (showAlbumSelector = false)} size="medium">
<AlbumSelector
mediaId={media.id}
currentAlbums={albums}
onUpdate={(updatedAlbums) => {
albums = updatedAlbums
showAlbumSelector = false
}}
onClose={() => (showAlbumSelector = false)}
/>
</Modal>
{/if}
{/if}
<style lang="scss">
@ -640,7 +712,7 @@
font-size: 0.875rem;
color: $grey-10;
font-weight: 500;
&.color-value {
display: flex;
align-items: center;
@ -648,7 +720,7 @@
}
}
}
.color-swatch {
display: inline-block;
width: 20px;
@ -670,12 +742,18 @@
}
}
.exif-data {
.media-metadata,
.exif-metadata {
display: grid;
grid-template-columns: 1fr 1fr;
gap: $unit-3x;
padding-top: $unit-3x;
border-top: 1px solid $grey-90;
}
.metadata-divider {
border-radius: 1px;
height: 2px;
background: $grey-80;
margin: $unit-3x 0;
}
.edit-form {
@ -763,6 +841,47 @@
}
.usage-section {
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: $unit-2x;
h4 {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: $grey-20;
}
}
.add-album-button {
display: flex;
align-items: center;
gap: $unit-half;
padding: $unit-half;
background: transparent;
border: none;
border-radius: 6px;
color: $grey-40;
cursor: pointer;
transition: all 0.2s ease;
font-size: 0.875rem;
font-weight: 500;
&:hover {
background: $grey-95;
color: $grey-20;
}
svg,
:global(svg) {
width: 16px;
height: 16px;
flex-shrink: 0;
}
}
.usage-list {
list-style: none;
padding: 0;
@ -856,6 +975,44 @@
}
}
// Albums inline display
.albums-inline {
margin-top: $unit-4x;
h4 {
font-size: 1rem;
font-weight: 600;
color: $grey-20;
margin: 0 0 $unit-2x 0;
}
}
.album-tags {
display: flex;
flex-wrap: wrap;
gap: $unit;
}
.album-tag {
display: inline-flex;
align-items: center;
padding: $unit-half $unit-2x;
background: $grey-95;
border: 1px solid $grey-90;
border-radius: 20px;
color: $grey-20;
text-decoration: none;
font-size: 0.875rem;
font-weight: 500;
transition: all 0.2s ease;
&:hover {
background: $grey-90;
border-color: $grey-85;
color: $grey-10;
}
}
.pane-footer {
display: flex;
align-items: center;

View file

@ -77,12 +77,8 @@
</script>
{#if isOpen}
<div class="modal-backdrop" on:click={handleBackdropClick} transition:fade={{ duration: 200 }}>
<div
class="modal {modalClass}"
on:click|stopPropagation
transition:fade={{ duration: 200, delay: 50 }}
>
<div class="modal-backdrop" on:click={handleBackdropClick} transition:fade={{ duration: 150 }}>
<div class="modal {modalClass}" on:click|stopPropagation transition:fade={{ duration: 150 }}>
{#if showCloseButton}
<Button
variant="ghost"

View file

@ -1,6 +1,6 @@
<script lang="ts">
import { goto } from '$app/navigation'
import UniverseComposer from './UniverseComposer.svelte'
import InlineComposerModal from './InlineComposerModal.svelte'
import Button from './Button.svelte'
import ChevronDownIcon from '$icons/chevron-down.svg?raw'
@ -115,7 +115,7 @@
{/if}
</div>
<UniverseComposer
<InlineComposerModal
bind:isOpen={showComposer}
initialPostType={selectedType}
on:close={handleComposerClose}