refactor: convert Media Library actions to dropdown menu

- Replace individual buttons with dropdown menu for secondary actions
- Add 'Audit Storage' and 'Select Files' options to dropdown
- Keep Upload as primary action button
- Add chevron-down icon with proper stroke styling
- Implement click-outside handler for dropdown

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Justin Edmund 2025-06-16 17:04:32 +01:00
parent 655a8a05a5
commit c6fd8cf292

View file

@ -6,8 +6,11 @@
import Input from '$lib/components/admin/Input.svelte' import Input from '$lib/components/admin/Input.svelte'
import Select from '$lib/components/admin/Select.svelte' import Select from '$lib/components/admin/Select.svelte'
import Button from '$lib/components/admin/Button.svelte' import Button from '$lib/components/admin/Button.svelte'
import DropdownMenuContainer from '$lib/components/admin/DropdownMenuContainer.svelte'
import DropdownItem from '$lib/components/admin/DropdownItem.svelte'
import MediaDetailsModal from '$lib/components/admin/MediaDetailsModal.svelte' import MediaDetailsModal from '$lib/components/admin/MediaDetailsModal.svelte'
import MediaUploadModal from '$lib/components/admin/MediaUploadModal.svelte' import MediaUploadModal from '$lib/components/admin/MediaUploadModal.svelte'
import ChevronDown from '$icons/chevron-down.svg'
import type { Media } from '@prisma/client' import type { Media } from '@prisma/client'
let media = $state<Media[]>([]) let media = $state<Media[]>([])
@ -20,9 +23,10 @@
// Filter states // Filter states
let filterType = $state<string>('all') let filterType = $state<string>('all')
let photographyFilter = $state<string>('all') let publishedFilter = $state<string>('all')
let searchQuery = $state('') let searchQuery = $state('')
let searchTimeout: ReturnType<typeof setTimeout> let searchTimeout: ReturnType<typeof setTimeout>
let sortBy = $state<string>('newest')
// Filter options // Filter options
const typeFilterOptions = [ const typeFilterOptions = [
@ -30,13 +34,23 @@
{ value: 'image', label: 'Images' }, { value: 'image', label: 'Images' },
{ value: 'video', label: 'Videos' }, { value: 'video', label: 'Videos' },
{ value: 'audio', label: 'Audio' }, { value: 'audio', label: 'Audio' },
{ value: 'application/pdf', label: 'PDFs' } { value: 'vector', label: 'Vectors' }
] ]
const photographyFilterOptions = [ const publishedFilterOptions = [
{ value: 'all', label: 'All media' }, { value: 'all', label: 'Published in' },
{ value: 'true', label: 'Photography only' }, { value: 'photos', label: 'Photos' },
{ value: 'false', label: 'Non-photography' } { value: 'universe', label: 'Universe' },
{ value: 'unpublished', label: 'Unpublished' }
]
const sortOptions = [
{ value: 'newest', label: 'Newest first' },
{ value: 'oldest', label: 'Oldest first' },
{ value: 'name-asc', label: 'Name (A-Z)' },
{ value: 'name-desc', label: 'Name (Z-A)' },
{ value: 'size-asc', label: 'Size (smallest)' },
{ value: 'size-desc', label: 'Size (largest)' }
] ]
// Modal states // Modal states
@ -48,6 +62,9 @@
let selectedMediaIds = $state<Set<number>>(new Set()) let selectedMediaIds = $state<Set<number>>(new Set())
let isMultiSelectMode = $state(false) let isMultiSelectMode = $state(false)
let isDeleting = $state(false) let isDeleting = $state(false)
// Dropdown state
let isDropdownOpen = $state(false)
onMount(async () => { onMount(async () => {
await loadMedia() await loadMedia()
@ -73,12 +90,15 @@
if (filterType !== 'all') { if (filterType !== 'all') {
url += `&mimeType=${filterType}` url += `&mimeType=${filterType}`
} }
if (photographyFilter !== 'all') { if (publishedFilter !== 'all') {
url += `&isPhotography=${photographyFilter}` url += `&publishedFilter=${publishedFilter}`
} }
if (searchQuery) { if (searchQuery) {
url += `&search=${encodeURIComponent(searchQuery)}` url += `&search=${encodeURIComponent(searchQuery)}`
} }
if (sortBy) {
url += `&sort=${sortBy}`
}
const response = await fetch(url, { const response = await fetch(url, {
headers: { Authorization: `Basic ${auth}` } headers: { Authorization: `Basic ${auth}` }
@ -113,6 +133,11 @@
loadMedia(1) loadMedia(1)
} }
function handleSortChange() {
currentPage = 1
loadMedia(1)
}
function formatFileSize(bytes: number): string { function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 Bytes' if (bytes === 0) return '0 Bytes'
const k = 1024 const k = 1024
@ -154,11 +179,36 @@
function openUploadModal() { function openUploadModal() {
isUploadModalOpen = true isUploadModalOpen = true
isDropdownOpen = false
} }
function handleDropdownToggle(e: MouseEvent) {
e.stopPropagation()
isDropdownOpen = !isDropdownOpen
}
function handleClickOutside(event: MouseEvent) {
const target = event.target as HTMLElement
if (!target.closest('.actions-dropdown')) {
isDropdownOpen = false
}
}
function handleAuditStorage() {
window.location.href = '/admin/media/audit'
}
$effect(() => {
if (isDropdownOpen) {
document.addEventListener('click', handleClickOutside)
return () => document.removeEventListener('click', handleClickOutside)
}
})
// Multiselect functions // Multiselect functions
function toggleMultiSelectMode() { function toggleMultiSelectMode() {
isMultiSelectMode = !isMultiSelectMode isMultiSelectMode = !isMultiSelectMode
isDropdownOpen = false
if (!isMultiSelectMode) { if (!isMultiSelectMode) {
selectedMediaIds.clear() selectedMediaIds.clear()
selectedMediaIds = new Set() selectedMediaIds = new Set()
@ -318,15 +368,28 @@
<AdminPage> <AdminPage>
<AdminHeader title="Media Library" slot="header"> <AdminHeader title="Media Library" slot="header">
{#snippet actions()} {#snippet actions()}
<Button <div class="actions-dropdown">
variant="secondary" <Button variant="primary" buttonSize="large" onclick={openUploadModal}>Upload</Button>
buttonSize="large" <Button
onclick={toggleMultiSelectMode} variant="ghost"
class={isMultiSelectMode ? 'active' : ''} iconOnly
> buttonSize="large"
{isMultiSelectMode ? 'Exit Select' : 'Select'} onclick={handleDropdownToggle}
</Button> >
<Button variant="primary" buttonSize="large" onclick={openUploadModal}>Upload</Button> <ChevronDown slot="icon" />
</Button>
{#if isDropdownOpen}
<DropdownMenuContainer>
<DropdownItem onclick={toggleMultiSelectMode}>
{isMultiSelectMode ? 'Exit Select' : 'Select Files'}
</DropdownItem>
<DropdownItem onclick={handleAuditStorage}>
Audit Storage
</DropdownItem>
</DropdownMenuContainer>
{/if}
</div>
{/snippet} {/snippet}
</AdminHeader> </AdminHeader>
@ -344,14 +407,21 @@
onchange={handleFilterChange} onchange={handleFilterChange}
/> />
<Select <Select
bind:value={photographyFilter} bind:value={publishedFilter}
options={photographyFilterOptions} options={publishedFilterOptions}
size="small" size="small"
variant="minimal" variant="minimal"
onchange={handleFilterChange} onchange={handleFilterChange}
/> />
{/snippet} {/snippet}
{#snippet right()} {#snippet right()}
<Select
bind:value={sortBy}
options={sortOptions}
size="small"
variant="minimal"
onchange={handleSortChange}
/>
<Input <Input
type="search" type="search"
bind:value={searchQuery} bind:value={searchQuery}
@ -563,6 +633,39 @@
} }
} }
.actions-dropdown {
position: relative;
display: flex;
gap: $unit-half;
:global(svg) {
width: 12px;
height: 12px;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
}
// Ensure search input matches filter dropdown sizing
:global(.admin-filters) {
:global(input[type="search"]) {
height: 36px; // Match Select component small size
font-size: 0.875rem; // Match Select component font size
min-width: 200px; // Wider to show full placeholder
}
// Make the sort dropdown narrower
:global(.admin-filters__right) {
:global(.select:first-child) {
min-width: 140px;
max-width: 160px;
}
}
}
.error { .error {
text-align: center; text-align: center;
padding: $unit-6x; padding: $unit-6x;