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:
parent
78663151a8
commit
0d4bf6d53f
10 changed files with 311 additions and 488 deletions
3
src/assets/icons/album.svg
Normal file
3
src/assets/icons/album.svg
Normal 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 |
4
src/assets/icons/media.svg
Normal file
4
src/assets/icons/media.svg
Normal 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 |
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Reference in a new issue