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:
parent
655a8a05a5
commit
c6fd8cf292
1 changed files with 122 additions and 19 deletions
|
|
@ -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
|
||||||
|
|
@ -49,6 +63,9 @@
|
||||||
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;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue