This commit is contained in:
Justin Edmund 2025-06-02 02:23:50 -07:00
parent 2f504abb57
commit 5e066093d8
9 changed files with 559 additions and 531 deletions

View file

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 6L8 10L12 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 217 B

View file

@ -0,0 +1,39 @@
<script lang="ts">
interface Props {
title: string
actions?: any
}
let { title, actions }: Props = $props()
</script>
<header>
<h1>{title}</h1>
{#if actions}
<div class="header-actions">
{@render actions()}
</div>
{/if}
</header>
<style lang="scss">
header {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
h1 {
font-size: 1.75rem;
font-weight: 700;
margin: 0;
color: $grey-10;
}
}
.header-actions {
display: flex;
align-items: center;
gap: $unit-2x;
}
</style>

View file

@ -48,6 +48,8 @@
} }
.page-header { .page-header {
box-sizing: border-box;
min-height: 110px;
padding: $unit-4x; padding: $unit-4x;
@include breakpoint('phone') { @include breakpoint('phone') {

View file

@ -2,6 +2,7 @@
import { goto } from '$app/navigation' import { goto } from '$app/navigation'
import UniverseComposer from './UniverseComposer.svelte' import UniverseComposer from './UniverseComposer.svelte'
import Button from './Button.svelte' import Button from './Button.svelte'
import ChevronDownIcon from '$icons/chevron-down.svg?raw'
let isOpen = $state(false) let isOpen = $state(false)
let buttonRef: HTMLElement let buttonRef: HTMLElement
@ -54,6 +55,7 @@
<Button <Button
bind:this={buttonRef} bind:this={buttonRef}
variant="primary" variant="primary"
size="large"
onclick={(e) => { onclick={(e) => {
e.stopPropagation() e.stopPropagation()
isOpen = !isOpen isOpen = !isOpen
@ -62,15 +64,9 @@
> >
New Post New Post
{#snippet icon()} {#snippet icon()}
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" class="chevron"> <div class="chevron">
<path {@html ChevronDownIcon}
d="M3 4.5L6 7.5L9 4.5" </div>
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
{/snippet} {/snippet}
</Button> </Button>
@ -114,8 +110,18 @@
stroke="currentColor" stroke="currentColor"
stroke-width="1.5" stroke-width="1.5"
/> />
<path d="M5 7H12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" /> <path
<path d="M5 9H10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" /> d="M5 7H12"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
/>
<path
d="M5 9H10"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
/>
</svg> </svg>
{/if} {/if}
</div> </div>
@ -142,22 +148,11 @@
position: relative; position: relative;
} }
// Button styles are now handled by the Button component
// Override primary button color to match original design
:global(.dropdown-container .btn-primary) {
background-color: $grey-10;
&:hover:not(:disabled) {
background-color: $grey-20;
}
&:active:not(:disabled) {
background-color: $grey-30;
}
}
.chevron { .chevron {
transition: transform 0.2s ease; transition: transform 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
} }
.dropdown-menu { .dropdown-menu {

View file

@ -1,162 +1,212 @@
<script lang="ts"> <script lang="ts">
import type { HTMLSelectAttributes } from 'svelte/elements'
import ChevronDownIcon from '$icons/chevron-down.svg?raw'
interface Option { interface Option {
value: string value: string
label: string label: string
} }
interface Props { interface Props extends Omit<HTMLSelectAttributes, 'size'> {
label?: string
value?: string
options: Option[] options: Option[]
error?: string value?: string
helpText?: string size?: 'small' | 'medium' | 'large'
required?: boolean variant?: 'default' | 'minimal'
disabled?: boolean fullWidth?: boolean
placeholder?: string pill?: boolean
class?: string
} }
let { let {
label,
value = $bindable(''),
options, options,
error, value = $bindable(),
helpText, size = 'medium',
required = false, variant = 'default',
disabled = false, fullWidth = false,
placeholder = 'Select an option', pill = true,
class: className = '' class: className = '',
...restProps
}: Props = $props() }: Props = $props()
</script> </script>
<div class="select-wrapper {className}"> <div class="select-wrapper">
{#if label}
<label class="select-label">
{label}
{#if required}
<span class="required">*</span>
{/if}
</label>
{/if}
<div class="select-container" class:error>
<select <select
bind:value bind:value
{disabled} class="select select-{size} select-{variant} {className}"
class="select-input" class:select-full-width={fullWidth}
class:error class:select-pill={pill}
{...restProps}
> >
{#if placeholder}
<option value="" disabled hidden>{placeholder}</option>
{/if}
{#each options as option} {#each options as option}
<option value={option.value}>{option.label}</option> <option value={option.value}>{option.label}</option>
{/each} {/each}
</select> </select>
<div class="select-arrow"> <div class="select-icon">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> {@html ChevronDownIcon}
<path d="M6 9L12 15L18 9" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div> </div>
</div>
{#if error}
<div class="error-message">{error}</div>
{/if}
{#if helpText && !error}
<div class="help-text">{helpText}</div>
{/if}
</div> </div>
<style lang="scss"> <style lang="scss">
.select-wrapper { .select-wrapper {
display: flex;
flex-direction: column;
gap: $unit-half;
width: 100%;
}
.select-label {
font-size: 0.875rem;
font-weight: 500;
color: $grey-20;
margin: 0;
.required {
color: $red-50;
margin-left: 2px;
}
}
.select-container {
position: relative; position: relative;
display: inline-block;
&.error {
.select-input {
border-color: $red-50;
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1);
}
}
} }
.select-input { .select {
width: 100%; box-sizing: border-box;
padding: $unit $unit-2x; color: $grey-20;
border: 1px solid $grey-80;
border-radius: $corner-radius;
background: $grey-100;
color: $grey-10;
font-size: 0.875rem;
line-height: 1.5;
appearance: none;
cursor: pointer; cursor: pointer;
font-family: inherit;
transition: all 0.2s ease; transition: all 0.2s ease;
appearance: none;
&:hover:not(:disabled) { padding-right: 36px;
border-color: $grey-70;
}
&:focus { &:focus {
outline: none; outline: none;
}
&:disabled {
color: $grey-60;
cursor: not-allowed;
opacity: 0.6;
}
// Default variant
&.select-default {
border: 1px solid $grey-80;
background: white;
font-weight: 500;
&:focus {
border-color: $blue-50; border-color: $blue-50;
box-shadow: 0 0 0 3px rgba(20, 130, 193, 0.1); box-shadow: 0 0 0 3px rgba(20, 130, 193, 0.1);
} }
&:disabled { &:disabled {
background: $grey-95; background: $grey-95;
color: $grey-60;
cursor: not-allowed;
}
&.error {
border-color: $red-50;
} }
} }
.select-arrow { // Minimal variant
&.select-minimal {
border: none;
background: transparent;
font-weight: 500;
&:hover {
background: $grey-95;
}
&:focus {
background: $grey-95;
}
&:disabled {
background: transparent;
}
}
// Size variants for default variant (accounting for border)
&.select-default {
&.select-small {
padding: calc($unit - 1px) calc($unit * 1.5);
font-size: 13px;
min-height: 28px;
min-width: 120px;
}
&.select-medium {
padding: calc($unit - 1px) $unit-2x;
font-size: 14px;
min-height: 36px;
min-width: 160px;
}
&.select-large {
padding: calc($unit * 1.5 - 1px) $unit-3x;
font-size: 15px;
min-height: 44px;
min-width: 180px;
}
}
// Size variants for minimal variant (no border, card-sized border radius)
&.select-minimal {
&.select-small {
padding: $unit calc($unit * 1.5);
font-size: 13px;
min-height: 28px;
min-width: 120px;
border-radius: 8px;
}
&.select-medium {
padding: $unit $unit-2x;
font-size: 14px;
min-height: 36px;
min-width: 160px;
border-radius: 8px;
}
&.select-large {
padding: calc($unit * 1.5) $unit-3x;
font-size: 15px;
min-height: 44px;
min-width: 180px;
border-radius: 8px;
}
}
// Shape variants for default variant only (minimal already has card radius)
&.select-default.select-pill {
&.select-small {
border-radius: 20px;
}
&.select-medium {
border-radius: 24px;
}
&.select-large {
border-radius: 28px;
}
}
&.select-default:not(.select-pill) {
&.select-small {
border-radius: 6px;
}
&.select-medium {
border-radius: 8px;
}
&.select-large {
border-radius: 10px;
}
}
// Width variants
&.select-full-width {
width: 100%;
min-width: auto;
}
}
.select-icon {
position: absolute; position: absolute;
right: $unit-2x; right: 12px;
top: 50%; top: 50%;
transform: translateY(-50%); transform: translateY(-50%);
color: $grey-40;
pointer-events: none; pointer-events: none;
transition: color 0.2s ease; color: $grey-60;
display: flex;
align-items: center;
justify-content: center;
:global(svg) {
width: 16px;
height: 16px;
}
} }
.select-container:hover .select-arrow { // Full width handling for wrapper
color: $grey-30; .select-wrapper:has(.select-full-width) {
} width: 100%;
.error-message {
font-size: 0.75rem;
color: $red-50;
margin: 0;
}
.help-text {
font-size: 0.75rem;
color: $grey-40;
margin: 0;
} }
</style> </style>

View file

@ -2,8 +2,10 @@
import { goto } from '$app/navigation' import { goto } from '$app/navigation'
import { onMount } from 'svelte' import { onMount } from 'svelte'
import AdminPage from '$lib/components/admin/AdminPage.svelte' import AdminPage from '$lib/components/admin/AdminPage.svelte'
import AdminHeader from '$lib/components/admin/AdminHeader.svelte'
import DataTable from '$lib/components/admin/DataTable.svelte' import DataTable from '$lib/components/admin/DataTable.svelte'
import Button from '$lib/components/admin/Button.svelte' import Button from '$lib/components/admin/Button.svelte'
import Select from '$lib/components/admin/Select.svelte'
import LoadingSpinner from '$lib/components/admin/LoadingSpinner.svelte' import LoadingSpinner from '$lib/components/admin/LoadingSpinner.svelte'
// State // State
@ -16,6 +18,13 @@
// Filter state // Filter state
let photographyFilter = $state<string>('all') let photographyFilter = $state<string>('all')
// Filter options
const filterOptions = [
{ value: 'all', label: 'All albums' },
{ value: 'true', label: 'Photography albums' },
{ value: 'false', label: 'Regular albums' }
]
const columns = [ const columns = [
{ {
key: 'title', key: 'title',
@ -100,11 +109,10 @@
// Calculate album type counts // Calculate album type counts
const counts: Record<string, number> = { const counts: Record<string, number> = {
all: albums.length, all: albums.length,
photography: albums.filter(a => a.isPhotography).length, photography: albums.filter((a) => a.isPhotography).length,
regular: albums.filter(a => !a.isPhotography).length regular: albums.filter((a) => !a.isPhotography).length
} }
albumTypeCounts = counts albumTypeCounts = counts
} catch (err) { } catch (err) {
error = 'Failed to load albums' error = 'Failed to load albums'
console.error(err) console.error(err)
@ -127,41 +135,24 @@
</script> </script>
<AdminPage> <AdminPage>
<header slot="header"> <AdminHeader title="Albums" slot="header">
<h1>Albums</h1> {#snippet actions()}
<div class="header-actions"> <Button variant="primary" size="large" onclick={handleNewAlbum}>New Album</Button>
<Button variant="primary" onclick={handleNewAlbum}> {/snippet}
New Album </AdminHeader>
</Button>
</div>
</header>
{#if error} {#if error}
<div class="error">{error}</div> <div class="error">{error}</div>
{:else} {:else}
<!-- Albums Stats -->
<div class="albums-stats">
<div class="stat">
<span class="stat-value">{albumTypeCounts.all || 0}</span>
<span class="stat-label">Total albums</span>
</div>
<div class="stat">
<span class="stat-value">{albumTypeCounts.photography || 0}</span>
<span class="stat-label">Photography albums</span>
</div>
<div class="stat">
<span class="stat-value">{albumTypeCounts.regular || 0}</span>
<span class="stat-label">Regular albums</span>
</div>
</div>
<!-- Filters --> <!-- Filters -->
<div class="filters"> <div class="filters">
<select bind:value={photographyFilter} onchange={handleFilterChange} class="filter-select"> <Select
<option value="all">All albums</option> bind:value={photographyFilter}
<option value="true">Photography albums</option> options={filterOptions}
<option value="false">Regular albums</option> size="small"
</select> variant="minimal"
onchange={handleFilterChange}
/>
</div> </div>
<!-- Albums Table --> <!-- Albums Table -->
@ -184,25 +175,6 @@
<style lang="scss"> <style lang="scss">
@import '$styles/variables.scss'; @import '$styles/variables.scss';
header {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
h1 {
font-size: 1.75rem;
font-weight: 700;
margin: 0;
color: $grey-10;
}
}
.header-actions {
display: flex;
gap: $unit-2x;
align-items: center;
}
.error { .error {
background: rgba(239, 68, 68, 0.1); background: rgba(239, 68, 68, 0.1);
@ -213,33 +185,6 @@
margin-bottom: $unit-4x; margin-bottom: $unit-4x;
} }
.albums-stats {
display: flex;
gap: $unit-4x;
margin-bottom: $unit-4x;
padding: $unit-4x;
background: $grey-95;
border-radius: $unit-2x;
.stat {
display: flex;
flex-direction: column;
align-items: center;
gap: $unit-half;
.stat-value {
font-size: 2rem;
font-weight: 700;
color: $grey-10;
}
.stat-label {
font-size: 0.875rem;
color: $grey-40;
}
}
}
.filters { .filters {
display: flex; display: flex;
gap: $unit-2x; gap: $unit-2x;
@ -247,21 +192,6 @@
margin-bottom: $unit-4x; margin-bottom: $unit-4x;
} }
.filter-select {
padding: $unit $unit-3x;
border: 1px solid $grey-80;
border-radius: 50px;
background: white;
font-size: 0.925rem;
color: $grey-20;
cursor: pointer;
&:focus {
outline: none;
border-color: $grey-40;
}
}
.loading-container { .loading-container {
display: flex; display: flex;
justify-content: center; justify-content: center;

View file

@ -120,7 +120,7 @@
function handleMediaUpdate(updatedMedia: Media) { function handleMediaUpdate(updatedMedia: Media) {
// Update the media item in the list // Update the media item in the list
const index = media.findIndex(m => m.id === updatedMedia.id) const index = media.findIndex((m) => m.id === updatedMedia.id)
if (index !== -1) { if (index !== -1) {
media[index] = updatedMedia media[index] = updatedMedia
} }
@ -145,7 +145,7 @@
} }
function selectAllMedia() { function selectAllMedia() {
selectedMediaIds = new Set(media.map(m => m.id)) selectedMediaIds = new Set(media.map((m) => m.id))
} }
function clearSelection() { function clearSelection() {
@ -170,7 +170,7 @@
const response = await fetch('/api/media/bulk-delete', { const response = await fetch('/api/media/bulk-delete', {
method: 'DELETE', method: 'DELETE',
headers: { headers: {
'Authorization': `Basic ${auth}`, Authorization: `Basic ${auth}`,
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },
body: JSON.stringify({ body: JSON.stringify({
@ -185,7 +185,7 @@
const result = await response.json() const result = await response.json()
// Remove deleted media from the list // Remove deleted media from the list
media = media.filter(m => !selectedMediaIds.has(m.id)) media = media.filter((m) => !selectedMediaIds.has(m.id))
// Clear selection and exit multiselect mode // Clear selection and exit multiselect mode
selectedMediaIds.clear() selectedMediaIds.clear()
@ -194,7 +194,6 @@
// Reload to get updated total count // Reload to get updated total count
await loadMedia(currentPage) await loadMedia(currentPage)
} catch (err) { } catch (err) {
error = 'Failed to delete media files. Please try again.' error = 'Failed to delete media files. Please try again.'
console.error('Failed to delete media:', err) console.error('Failed to delete media:', err)
@ -215,7 +214,7 @@
const response = await fetch(`/api/media/${mediaId}`, { const response = await fetch(`/api/media/${mediaId}`, {
method: 'PUT', method: 'PUT',
headers: { headers: {
'Authorization': `Basic ${auth}`, Authorization: `Basic ${auth}`,
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },
body: JSON.stringify({ isPhotography: true }) body: JSON.stringify({ isPhotography: true })
@ -230,17 +229,14 @@
await Promise.all(promises) await Promise.all(promises)
// Update local media items // Update local media items
media = media.map(item => media = media.map((item) =>
selectedMediaIds.has(item.id) selectedMediaIds.has(item.id) ? { ...item, isPhotography: true } : item
? { ...item, isPhotography: true }
: item
) )
// Clear selection // Clear selection
selectedMediaIds.clear() selectedMediaIds.clear()
selectedMediaIds = new Set() selectedMediaIds = new Set()
isMultiSelectMode = false isMultiSelectMode = false
} catch (err) { } catch (err) {
error = 'Failed to mark items as photography. Please try again.' error = 'Failed to mark items as photography. Please try again.'
console.error('Failed to mark as photography:', err) console.error('Failed to mark as photography:', err)
@ -259,7 +255,7 @@
const response = await fetch(`/api/media/${mediaId}`, { const response = await fetch(`/api/media/${mediaId}`, {
method: 'PUT', method: 'PUT',
headers: { headers: {
'Authorization': `Basic ${auth}`, Authorization: `Basic ${auth}`,
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },
body: JSON.stringify({ isPhotography: false }) body: JSON.stringify({ isPhotography: false })
@ -274,17 +270,14 @@
await Promise.all(promises) await Promise.all(promises)
// Update local media items // Update local media items
media = media.map(item => media = media.map((item) =>
selectedMediaIds.has(item.id) selectedMediaIds.has(item.id) ? { ...item, isPhotography: false } : item
? { ...item, isPhotography: false }
: item
) )
// Clear selection // Clear selection
selectedMediaIds.clear() selectedMediaIds.clear()
selectedMediaIds = new Set() selectedMediaIds = new Set()
isMultiSelectMode = false isMultiSelectMode = false
} catch (err) { } catch (err) {
error = 'Failed to remove photography status. Please try again.' error = 'Failed to remove photography status. Please try again.'
console.error('Failed to unmark photography:', err) console.error('Failed to unmark photography:', err)
@ -319,19 +312,6 @@
<div class="error">{error}</div> <div class="error">{error}</div>
{:else} {:else}
<div class="media-controls"> <div class="media-controls">
<div class="media-stats">
<div class="stat">
<span class="stat-value">{total}</span>
<span class="stat-label">Total files</span>
</div>
{#if isMultiSelectMode}
<div class="stat">
<span class="stat-value">{selectedMediaIds.size}</span>
<span class="stat-label">Selected</span>
</div>
{/if}
</div>
<div class="filters"> <div class="filters">
<select bind:value={filterType} onchange={handleFilterChange} class="filter-select"> <select bind:value={filterType} onchange={handleFilterChange} class="filter-select">
<option value="all">All types</option> <option value="all">All types</option>
@ -357,8 +337,19 @@
prefixIcon prefixIcon
wrapperClass="search-input-wrapper" wrapperClass="search-input-wrapper"
> >
<svg slot="prefix" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> <svg
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" /> slot="prefix"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
/>
</svg> </svg>
</Input> </Input>
</div> </div>
@ -403,7 +394,9 @@
class="btn btn-danger btn-small" class="btn btn-danger btn-small"
disabled={isDeleting} disabled={isDeleting}
> >
{isDeleting ? 'Deleting...' : `Delete ${selectedMediaIds.size} file${selectedMediaIds.size > 1 ? 's' : ''}`} {isDeleting
? 'Deleting...'
: `Delete ${selectedMediaIds.size} file${selectedMediaIds.size > 1 ? 's' : ''}`}
</button> </button>
{/if} {/if}
</div> </div>
@ -435,12 +428,16 @@
<button <button
class="media-item" class="media-item"
type="button" type="button"
onclick={() => isMultiSelectMode ? toggleMediaSelection(item.id) : handleMediaClick(item)} onclick={() =>
isMultiSelectMode ? toggleMediaSelection(item.id) : handleMediaClick(item)}
title="{isMultiSelectMode ? 'Click to select' : 'Click to edit'} {item.filename}" title="{isMultiSelectMode ? 'Click to select' : 'Click to edit'} {item.filename}"
class:selected={isMultiSelectMode && selectedMediaIds.has(item.id)} class:selected={isMultiSelectMode && selectedMediaIds.has(item.id)}
> >
{#if item.mimeType.startsWith('image/')} {#if item.mimeType.startsWith('image/')}
<img src={item.mimeType === 'image/svg+xml' ? item.url : (item.thumbnailUrl || item.url)} alt={item.altText || item.filename} /> <img
src={item.mimeType === 'image/svg+xml' ? item.url : item.thumbnailUrl || item.url}
alt={item.altText || item.filename}
/>
{:else} {:else}
<div class="file-placeholder"> <div class="file-placeholder">
<span class="file-type">{getFileType(item.mimeType)}</span> <span class="file-type">{getFileType(item.mimeType)}</span>
@ -451,8 +448,17 @@
<div class="media-indicators"> <div class="media-indicators">
{#if item.isPhotography} {#if item.isPhotography}
<span class="indicator-pill photography" title="Photography"> <span class="indicator-pill photography" title="Photography">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg
<polygon points="12,2 15.09,8.26 22,9 17,14.74 18.18,21.02 12,17.77 5.82,21.02 7,14.74 2,9 8.91,8.26" fill="currentColor"/> width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<polygon
points="12,2 15.09,8.26 22,9 17,14.74 18.18,21.02 12,17.77 5.82,21.02 7,14.74 2,9 8.91,8.26"
fill="currentColor"
/>
</svg> </svg>
Photo Photo
</span> </span>
@ -462,9 +468,7 @@
Alt Alt
</span> </span>
{:else} {:else}
<span class="indicator-pill no-alt-text" title="No alt text"> <span class="indicator-pill no-alt-text" title="No alt text"> No Alt </span>
No Alt
</span>
{/if} {/if}
</div> </div>
<span class="filesize">{formatFileSize(item.size)}</span> <span class="filesize">{formatFileSize(item.size)}</span>
@ -491,13 +495,19 @@
<button <button
class="media-row" class="media-row"
type="button" type="button"
onclick={() => isMultiSelectMode ? toggleMediaSelection(item.id) : handleMediaClick(item)} onclick={() =>
isMultiSelectMode ? toggleMediaSelection(item.id) : handleMediaClick(item)}
title="{isMultiSelectMode ? 'Click to select' : 'Click to edit'} {item.filename}" title="{isMultiSelectMode ? 'Click to select' : 'Click to edit'} {item.filename}"
class:selected={isMultiSelectMode && selectedMediaIds.has(item.id)} class:selected={isMultiSelectMode && selectedMediaIds.has(item.id)}
> >
<div class="media-preview"> <div class="media-preview">
{#if item.mimeType.startsWith('image/')} {#if item.mimeType.startsWith('image/')}
<img src={item.mimeType === 'image/svg+xml' ? item.url : (item.thumbnailUrl || item.url)} alt={item.altText || item.filename} /> <img
src={item.mimeType === 'image/svg+xml'
? item.url
: item.thumbnailUrl || item.url}
alt={item.altText || item.filename}
/>
{:else} {:else}
<div class="file-icon">{getFileType(item.mimeType)}</div> <div class="file-icon">{getFileType(item.mimeType)}</div>
{/if} {/if}
@ -508,8 +518,17 @@
<div class="media-indicators"> <div class="media-indicators">
{#if item.isPhotography} {#if item.isPhotography}
<span class="indicator-pill photography" title="Photography"> <span class="indicator-pill photography" title="Photography">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg
<polygon points="12,2 15.09,8.26 22,9 17,14.74 18.18,21.02 12,17.77 5.82,21.02 7,14.74 2,9 8.91,8.26" fill="currentColor"/> width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<polygon
points="12,2 15.09,8.26 22,9 17,14.74 18.18,21.02 12,17.77 5.82,21.02 7,14.74 2,9 8.91,8.26"
fill="currentColor"
/>
</svg> </svg>
Photo Photo
</span> </span>
@ -519,9 +538,7 @@
Alt Alt
</span> </span>
{:else} {:else}
<span class="indicator-pill no-alt-text" title="No alt text"> <span class="indicator-pill no-alt-text" title="No alt text"> No Alt </span>
No Alt
</span>
{/if} {/if}
</div> </div>
</div> </div>
@ -541,8 +558,20 @@
</div> </div>
<div class="media-indicator"> <div class="media-indicator">
{#if !isMultiSelectMode} {#if !isMultiSelectMode}
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg
<path d="M9 18L15 12L9 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M9 18L15 12L9 6"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg> </svg>
{/if} {/if}
</div> </div>
@ -648,25 +677,6 @@
flex-wrap: wrap; flex-wrap: wrap;
} }
.media-stats {
.stat {
display: flex;
flex-direction: column;
gap: $unit-half;
.stat-value {
font-size: 1.5rem;
font-weight: 700;
color: $grey-10;
}
.stat-label {
font-size: 0.875rem;
color: $grey-40;
}
}
}
.filters { .filters {
display: flex; display: flex;
gap: $unit-2x; gap: $unit-2x;
@ -999,7 +1009,7 @@
left: $unit; left: $unit;
z-index: 10; z-index: 10;
input[type="checkbox"] { input[type='checkbox'] {
opacity: 0; opacity: 0;
position: absolute; position: absolute;
pointer-events: none; pointer-events: none;

View file

@ -2,8 +2,10 @@
import { onMount } from 'svelte' import { onMount } from 'svelte'
import { goto } from '$app/navigation' import { goto } from '$app/navigation'
import AdminPage from '$lib/components/admin/AdminPage.svelte' import AdminPage from '$lib/components/admin/AdminPage.svelte'
import AdminHeader from '$lib/components/admin/AdminHeader.svelte'
import PostDropdown from '$lib/components/admin/PostDropdown.svelte' import PostDropdown from '$lib/components/admin/PostDropdown.svelte'
import LoadingSpinner from '$lib/components/admin/LoadingSpinner.svelte' import LoadingSpinner from '$lib/components/admin/LoadingSpinner.svelte'
import Select from '$lib/components/admin/Select.svelte'
interface Post { interface Post {
id: number id: number
@ -32,6 +34,13 @@
// Filter state // Filter state
let selectedFilter = $state<string>('all') let selectedFilter = $state<string>('all')
// Create filter options
const filterOptions = $derived([
{ value: 'all', label: 'All posts' },
{ value: 'post', label: 'Posts' },
{ value: 'essay', label: 'Essays' }
])
const postTypeIcons: Record<string, string> = { const postTypeIcons: Record<string, string> = {
post: '💭', post: '💭',
essay: '📝', essay: '📝',
@ -115,15 +124,13 @@
if (selectedFilter === 'all') { if (selectedFilter === 'all') {
filteredPosts = posts filteredPosts = posts
} else if (selectedFilter === 'post') { } else if (selectedFilter === 'post') {
filteredPosts = posts.filter(post => filteredPosts = posts.filter((post) =>
['post', 'microblog', 'link', 'photo'].includes(post.postType) ['post', 'microblog', 'link', 'photo'].includes(post.postType)
) )
} else if (selectedFilter === 'essay') { } else if (selectedFilter === 'essay') {
filteredPosts = posts.filter(post => filteredPosts = posts.filter((post) => ['essay', 'blog'].includes(post.postType))
['essay', 'blog'].includes(post.postType)
)
} else { } else {
filteredPosts = posts.filter(post => post.postType === selectedFilter) filteredPosts = posts.filter((post) => post.postType === selectedFilter)
} }
} }
@ -166,7 +173,9 @@
// Fallback to link description for link posts // Fallback to link description for link posts
if (post.linkDescription) { if (post.linkDescription) {
return post.linkDescription.length > 150 ? post.linkDescription.substring(0, 150) + '...' : post.linkDescription return post.linkDescription.length > 150
? post.linkDescription.substring(0, 150) + '...'
: post.linkDescription
} }
// Default fallback // Default fallback
@ -208,7 +217,10 @@
} }
const snippet = getPostSnippet(post) const snippet = getPostSnippet(post)
if (snippet && snippet !== `${postTypeLabels[post.postType] || post.postType} without content`) { if (
snippet &&
snippet !== `${postTypeLabels[post.postType] || post.postType} without content`
) {
return snippet.length > 50 ? snippet.substring(0, 50) + '...' : snippet return snippet.length > 50 ? snippet.substring(0, 50) + '...' : snippet
} }
@ -217,35 +229,24 @@
</script> </script>
<AdminPage> <AdminPage>
<header slot="header"> <AdminHeader title="Universe" slot="header">
<h1>Universe</h1> {#snippet actions()}
<div class="header-actions">
<select bind:value={selectedFilter} onchange={handleFilterChange} class="filter-select">
<option value="all">All posts ({postTypeCounts.all || 0})</option>
<option value="post">Posts ({postTypeCounts.post || 0})</option>
<option value="essay">Essays ({postTypeCounts.essay || 0})</option>
</select>
<PostDropdown /> <PostDropdown />
</div> {/snippet}
</header> </AdminHeader>
{#if error} {#if error}
<div class="error-message">{error}</div> <div class="error-message">{error}</div>
{:else} {:else}
<!-- Stats --> <!-- Filters -->
<div class="posts-stats"> <div class="filters">
<div class="stat"> <Select
<span class="stat-value">{postTypeCounts.all || 0}</span> bind:value={selectedFilter}
<span class="stat-label">Total posts</span> options={filterOptions}
</div> size="small"
<div class="stat"> variant="minimal"
<span class="stat-value">{postTypeCounts.post || 0}</span> onchange={handleFilterChange}
<span class="stat-label">Posts</span> />
</div>
<div class="stat">
<span class="stat-value">{postTypeCounts.essay || 0}</span>
<span class="stat-label">Essays</span>
</div>
</div> </div>
<!-- Posts List --> <!-- Posts List -->
@ -291,9 +292,27 @@
{#if post.linkUrl} {#if post.linkUrl}
<div class="post-link"> <div class="post-link">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> width="14"
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> height="14"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg> </svg>
<span class="link-url">{post.linkUrl}</span> <span class="link-url">{post.linkUrl}</span>
</div> </div>
@ -318,8 +337,20 @@
<span class="edit-hint">Click to edit</span> <span class="edit-hint">Click to edit</span>
</div> </div>
<div class="post-indicator"> <div class="post-indicator">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg
<path d="M9 18l6-6-6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M9 18l6-6-6-6"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg> </svg>
</div> </div>
</div> </div>
@ -333,40 +364,11 @@
<style lang="scss"> <style lang="scss">
@import '$styles/variables.scss'; @import '$styles/variables.scss';
header { .filters {
display: flex; display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
h1 {
font-size: 1.75rem;
font-weight: 700;
margin: 0;
color: $grey-10;
}
}
.header-actions {
display: flex;
align-items: center;
gap: $unit-2x; gap: $unit-2x;
align-items: center;
.filter-select { margin-bottom: $unit-4x;
padding: $unit $unit-3x;
border: 1px solid $grey-80;
border-radius: 50px;
background: white;
font-size: 0.925rem;
color: $grey-20;
cursor: pointer;
min-width: 160px;
&:focus {
outline: none;
border-color: $grey-40;
}
}
} }
.error-message { .error-message {
@ -379,38 +381,6 @@
margin-bottom: $unit-4x; margin-bottom: $unit-4x;
} }
.posts-stats {
display: flex;
gap: $unit-4x;
margin-bottom: $unit-4x;
padding: $unit-4x;
background: $grey-95;
border-radius: $unit-2x;
@media (max-width: 480px) {
gap: $unit-3x;
padding: $unit-3x;
}
.stat {
display: flex;
flex-direction: column;
align-items: center;
gap: $unit-half;
.stat-value {
font-size: 2rem;
font-weight: 700;
color: $grey-10;
}
.stat-label {
font-size: 0.875rem;
color: $grey-40;
}
}
}
.loading-container { .loading-container {
display: flex; display: flex;
justify-content: center; justify-content: center;

View file

@ -2,8 +2,11 @@
import { onMount } from 'svelte' import { onMount } from 'svelte'
import { goto } from '$app/navigation' import { goto } from '$app/navigation'
import AdminPage from '$lib/components/admin/AdminPage.svelte' import AdminPage from '$lib/components/admin/AdminPage.svelte'
import AdminHeader from '$lib/components/admin/AdminHeader.svelte'
import ProjectListItem from '$lib/components/admin/ProjectListItem.svelte' import ProjectListItem from '$lib/components/admin/ProjectListItem.svelte'
import DeleteConfirmationModal from '$lib/components/admin/DeleteConfirmationModal.svelte' import DeleteConfirmationModal from '$lib/components/admin/DeleteConfirmationModal.svelte'
import Button from '$lib/components/admin/Button.svelte'
import Select from '$lib/components/admin/Select.svelte'
interface Project { interface Project {
id: number id: number
@ -19,11 +22,23 @@
} }
let projects = $state<Project[]>([]) let projects = $state<Project[]>([])
let filteredProjects = $state<Project[]>([])
let isLoading = $state(true) let isLoading = $state(true)
let error = $state('') let error = $state('')
let showDeleteModal = $state(false) let showDeleteModal = $state(false)
let projectToDelete = $state<Project | null>(null) let projectToDelete = $state<Project | null>(null)
let activeDropdown = $state<number | null>(null) let activeDropdown = $state<number | null>(null)
let statusCounts = $state<Record<string, number>>({})
// Filter state
let selectedFilter = $state<string>('all')
// Create filter options
const filterOptions = $derived([
{ value: 'all', label: 'All projects' },
{ value: 'published', label: 'Published' },
{ value: 'draft', label: 'Draft' }
])
onMount(async () => { onMount(async () => {
await loadProjects() await loadProjects()
@ -61,6 +76,17 @@
const data = await response.json() const data = await response.json()
projects = data.projects projects = data.projects
// Calculate status counts
const counts: Record<string, number> = {
all: projects.length,
published: projects.filter((p) => p.status === 'published').length,
draft: projects.filter((p) => p.status === 'draft').length
}
statusCounts = counts
// Apply initial filter
applyFilter()
} catch (err) { } catch (err) {
error = 'Failed to load projects' error = 'Failed to load projects'
console.error(err) console.error(err)
@ -139,30 +165,60 @@
showDeleteModal = false showDeleteModal = false
projectToDelete = null projectToDelete = null
} }
function applyFilter() {
if (selectedFilter === 'all') {
filteredProjects = projects
} else {
filteredProjects = projects.filter((project) => project.status === selectedFilter)
}
}
function handleFilterChange() {
applyFilter()
}
</script> </script>
<AdminPage> <AdminPage>
<header slot="header"> <AdminHeader title="Projects" slot="header">
<h1>Projects</h1> {#snippet actions()}
<div class="header-actions"> <Button variant="primary" size="large" href="/admin/projects/new">New Project</Button>
<a href="/admin/projects/new" class="btn btn-primary">New Project</a> {/snippet}
</div> </AdminHeader>
</header>
{#if error} {#if error}
<div class="error">{error}</div> <div class="error">{error}</div>
{:else if isLoading} {:else}
<!-- Filters -->
<div class="filters">
<Select
bind:value={selectedFilter}
options={filterOptions}
size="small"
variant="minimal"
onchange={handleFilterChange}
/>
</div>
<!-- Projects List -->
{#if isLoading}
<div class="loading"> <div class="loading">
<div class="spinner"></div> <div class="spinner"></div>
<p>Loading projects...</p> <p>Loading projects...</p>
</div> </div>
{:else if projects.length === 0} {:else if filteredProjects.length === 0}
<div class="empty-state"> <div class="empty-state">
<p>No projects found. Create your first project!</p> <p>
{#if selectedFilter === 'all'}
No projects found. Create your first project!
{:else}
No {selectedFilter} projects found. Try a different filter or create a new project.
{/if}
</p>
</div> </div>
{:else} {:else}
<div class="projects-list"> <div class="projects-list">
{#each projects as project} {#each filteredProjects as project}
<ProjectListItem <ProjectListItem
{project} {project}
isDropdownActive={activeDropdown === project.id} isDropdownActive={activeDropdown === project.id}
@ -174,53 +230,26 @@
{/each} {/each}
</div> </div>
{/if} {/if}
{/if}
</AdminPage> </AdminPage>
<DeleteConfirmationModal <DeleteConfirmationModal
bind:isOpen={showDeleteModal} bind:isOpen={showDeleteModal}
title="Delete project?" title="Delete project?"
message={projectToDelete ? `Are you sure you want to delete "${projectToDelete.title}"? This action cannot be undone.` : ''} message={projectToDelete
? `Are you sure you want to delete "${projectToDelete.title}"? This action cannot be undone.`
: ''}
onConfirm={confirmDelete} onConfirm={confirmDelete}
onCancel={cancelDelete} onCancel={cancelDelete}
/> />
<style lang="scss"> <style lang="scss">
header {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
h1 { .filters {
font-size: 1.75rem;
font-weight: 700;
margin: 0;
color: $grey-10;
}
}
.header-actions {
display: flex; display: flex;
gap: $unit-2x; gap: $unit-2x;
} align-items: center;
margin-bottom: $unit-4x;
.btn {
padding: $unit-2x $unit-3x;
border-radius: 50px;
text-decoration: none;
font-size: 0.925rem;
transition: all 0.2s ease;
border: none;
cursor: pointer;
&.btn-primary {
background-color: $grey-10;
color: white;
&:hover {
background-color: $grey-20;
}
}
} }
.error { .error {