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 {
box-sizing: border-box;
min-height: 110px;
padding: $unit-4x;
@include breakpoint('phone') {

View file

@ -2,6 +2,7 @@
import { goto } from '$app/navigation'
import UniverseComposer from './UniverseComposer.svelte'
import Button from './Button.svelte'
import ChevronDownIcon from '$icons/chevron-down.svg?raw'
let isOpen = $state(false)
let buttonRef: HTMLElement
@ -54,6 +55,7 @@
<Button
bind:this={buttonRef}
variant="primary"
size="large"
onclick={(e) => {
e.stopPropagation()
isOpen = !isOpen
@ -62,15 +64,9 @@
>
New Post
{#snippet icon()}
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" class="chevron">
<path
d="M3 4.5L6 7.5L9 4.5"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
<div class="chevron">
{@html ChevronDownIcon}
</div>
{/snippet}
</Button>
@ -86,39 +82,49 @@
>
{#snippet icon()}
<div class="dropdown-icon">
{#if type.value === 'essay'}
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path
d="M3 5C3 3.89543 3.89543 3 5 3H11L17 9V15C17 16.1046 16.1046 17 15 17H5C3.89543 17 3 16.1046 3 15V5Z"
stroke="currentColor"
stroke-width="1.5"
/>
<path d="M11 3V9H17" stroke="currentColor" stroke-width="1.5" />
<path
d="M7 13H13"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
/>
<path
d="M7 10H13"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
/>
</svg>
{:else if type.value === 'post'}
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path
d="M4 3C2.89543 3 2 3.89543 2 5V11C2 12.1046 2.89543 13 4 13H6L8 16V13H13C14.1046 13 15 12.1046 15 11V5C15 3.89543 14.1046 3 13 3H4Z"
stroke="currentColor"
stroke-width="1.5"
/>
<path 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>
{/if}
</div>
{#if type.value === 'essay'}
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path
d="M3 5C3 3.89543 3.89543 3 5 3H11L17 9V15C17 16.1046 16.1046 17 15 17H5C3.89543 17 3 16.1046 3 15V5Z"
stroke="currentColor"
stroke-width="1.5"
/>
<path d="M11 3V9H17" stroke="currentColor" stroke-width="1.5" />
<path
d="M7 13H13"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
/>
<path
d="M7 10H13"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
/>
</svg>
{:else if type.value === 'post'}
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path
d="M4 3C2.89543 3 2 3.89543 2 5V11C2 12.1046 2.89543 13 4 13H6L8 16V13H13C14.1046 13 15 12.1046 15 11V5C15 3.89543 14.1046 3 13 3H4Z"
stroke="currentColor"
stroke-width="1.5"
/>
<path
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>
{/if}
</div>
{/snippet}
<span class="dropdown-label">{type.label}</span>
</Button>
@ -142,22 +148,11 @@
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 {
transition: transform 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
}
.dropdown-menu {

View file

@ -1,162 +1,212 @@
<script lang="ts">
import type { HTMLSelectAttributes } from 'svelte/elements'
import ChevronDownIcon from '$icons/chevron-down.svg?raw'
interface Option {
value: string
label: string
}
interface Props {
label?: string
value?: string
interface Props extends Omit<HTMLSelectAttributes, 'size'> {
options: Option[]
error?: string
helpText?: string
required?: boolean
disabled?: boolean
placeholder?: string
class?: string
value?: string
size?: 'small' | 'medium' | 'large'
variant?: 'default' | 'minimal'
fullWidth?: boolean
pill?: boolean
}
let {
label,
value = $bindable(''),
options,
error,
helpText,
required = false,
disabled = false,
placeholder = 'Select an option',
class: className = ''
value = $bindable(),
size = 'medium',
variant = 'default',
fullWidth = false,
pill = true,
class: className = '',
...restProps
}: Props = $props()
</script>
<div class="select-wrapper {className}">
{#if label}
<label class="select-label">
{label}
{#if required}
<span class="required">*</span>
{/if}
</label>
{/if}
<div class="select-container" class:error>
<select
bind:value
{disabled}
class="select-input"
class:error
>
{#if placeholder}
<option value="" disabled hidden>{placeholder}</option>
{/if}
{#each options as option}
<option value={option.value}>{option.label}</option>
{/each}
</select>
<div class="select-arrow">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 9L12 15L18 9" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<div class="select-wrapper">
<select
bind:value
class="select select-{size} select-{variant} {className}"
class:select-full-width={fullWidth}
class:select-pill={pill}
{...restProps}
>
{#each options as option}
<option value={option.value}>{option.label}</option>
{/each}
</select>
<div class="select-icon">
{@html ChevronDownIcon}
</div>
{#if error}
<div class="error-message">{error}</div>
{/if}
{#if helpText && !error}
<div class="help-text">{helpText}</div>
{/if}
</div>
<style lang="scss">
.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;
&.error {
.select-input {
border-color: $red-50;
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1);
}
}
display: inline-block;
}
.select-input {
width: 100%;
padding: $unit $unit-2x;
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;
.select {
box-sizing: border-box;
color: $grey-20;
cursor: pointer;
font-family: inherit;
transition: all 0.2s ease;
&:hover:not(:disabled) {
border-color: $grey-70;
}
appearance: none;
padding-right: 36px;
&:focus {
outline: none;
border-color: $blue-50;
box-shadow: 0 0 0 3px rgba(20, 130, 193, 0.1);
}
&:disabled {
background: $grey-95;
color: $grey-60;
cursor: not-allowed;
opacity: 0.6;
}
&.error {
border-color: $red-50;
// Default variant
&.select-default {
border: 1px solid $grey-80;
background: white;
font-weight: 500;
&:focus {
border-color: $blue-50;
box-shadow: 0 0 0 3px rgba(20, 130, 193, 0.1);
}
&:disabled {
background: $grey-95;
}
}
// 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-arrow {
.select-icon {
position: absolute;
right: $unit-2x;
right: 12px;
top: 50%;
transform: translateY(-50%);
color: $grey-40;
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 {
color: $grey-30;
// Full width handling for wrapper
.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 { onMount } from '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 Button from '$lib/components/admin/Button.svelte'
import Select from '$lib/components/admin/Select.svelte'
import LoadingSpinner from '$lib/components/admin/LoadingSpinner.svelte'
// State
@ -16,6 +18,13 @@
// Filter state
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 = [
{
key: 'title',
@ -100,11 +109,10 @@
// Calculate album type counts
const counts: Record<string, number> = {
all: albums.length,
photography: albums.filter(a => a.isPhotography).length,
regular: albums.filter(a => !a.isPhotography).length
photography: albums.filter((a) => a.isPhotography).length,
regular: albums.filter((a) => !a.isPhotography).length
}
albumTypeCounts = counts
} catch (err) {
error = 'Failed to load albums'
console.error(err)
@ -127,41 +135,24 @@
</script>
<AdminPage>
<header slot="header">
<h1>Albums</h1>
<div class="header-actions">
<Button variant="primary" onclick={handleNewAlbum}>
New Album
</Button>
</div>
</header>
<AdminHeader title="Albums" slot="header">
{#snippet actions()}
<Button variant="primary" size="large" onclick={handleNewAlbum}>New Album</Button>
{/snippet}
</AdminHeader>
{#if error}
<div class="error">{error}</div>
{: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 -->
<div class="filters">
<select bind:value={photographyFilter} onchange={handleFilterChange} class="filter-select">
<option value="all">All albums</option>
<option value="true">Photography albums</option>
<option value="false">Regular albums</option>
</select>
<Select
bind:value={photographyFilter}
options={filterOptions}
size="small"
variant="minimal"
onchange={handleFilterChange}
/>
</div>
<!-- Albums Table -->
@ -184,25 +175,6 @@
<style lang="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 {
background: rgba(239, 68, 68, 0.1);
@ -213,33 +185,6 @@
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 {
display: flex;
gap: $unit-2x;
@ -247,25 +192,10 @@
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 {
display: flex;
justify-content: center;
align-items: center;
min-height: 400px;
}
</style>
</style>

View file

@ -120,7 +120,7 @@
function handleMediaUpdate(updatedMedia: Media) {
// 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) {
media[index] = updatedMedia
}
@ -145,7 +145,7 @@
}
function selectAllMedia() {
selectedMediaIds = new Set(media.map(m => m.id))
selectedMediaIds = new Set(media.map((m) => m.id))
}
function clearSelection() {
@ -155,11 +155,11 @@
async function handleBulkDelete() {
if (selectedMediaIds.size === 0) return
const confirmation = confirm(
`Are you sure you want to delete ${selectedMediaIds.size} media file${selectedMediaIds.size > 1 ? 's' : ''}? This action cannot be undone and will remove these files from any content that references them.`
)
if (!confirmation) return
try {
@ -170,11 +170,11 @@
const response = await fetch('/api/media/bulk-delete', {
method: 'DELETE',
headers: {
'Authorization': `Basic ${auth}`,
Authorization: `Basic ${auth}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
mediaIds: Array.from(selectedMediaIds)
body: JSON.stringify({
mediaIds: Array.from(selectedMediaIds)
})
})
@ -183,10 +183,10 @@
}
const result = await response.json()
// 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
selectedMediaIds.clear()
selectedMediaIds = new Set()
@ -194,7 +194,6 @@
// Reload to get updated total count
await loadMedia(currentPage)
} catch (err) {
error = 'Failed to delete media files. Please try again.'
console.error('Failed to delete media:', err)
@ -215,7 +214,7 @@
const response = await fetch(`/api/media/${mediaId}`, {
method: 'PUT',
headers: {
'Authorization': `Basic ${auth}`,
Authorization: `Basic ${auth}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ isPhotography: true })
@ -230,17 +229,14 @@
await Promise.all(promises)
// Update local media items
media = media.map(item =>
selectedMediaIds.has(item.id)
? { ...item, isPhotography: true }
: item
media = media.map((item) =>
selectedMediaIds.has(item.id) ? { ...item, isPhotography: true } : item
)
// Clear selection
selectedMediaIds.clear()
selectedMediaIds = new Set()
isMultiSelectMode = false
} catch (err) {
error = 'Failed to mark items as photography. Please try again.'
console.error('Failed to mark as photography:', err)
@ -259,7 +255,7 @@
const response = await fetch(`/api/media/${mediaId}`, {
method: 'PUT',
headers: {
'Authorization': `Basic ${auth}`,
Authorization: `Basic ${auth}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ isPhotography: false })
@ -274,17 +270,14 @@
await Promise.all(promises)
// Update local media items
media = media.map(item =>
selectedMediaIds.has(item.id)
? { ...item, isPhotography: false }
: item
media = media.map((item) =>
selectedMediaIds.has(item.id) ? { ...item, isPhotography: false } : item
)
// Clear selection
selectedMediaIds.clear()
selectedMediaIds = new Set()
isMultiSelectMode = false
} catch (err) {
error = 'Failed to remove photography status. Please try again.'
console.error('Failed to unmark photography:', err)
@ -319,19 +312,6 @@
<div class="error">{error}</div>
{:else}
<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">
<select bind:value={filterType} onchange={handleFilterChange} class="filter-select">
<option value="all">All types</option>
@ -357,8 +337,19 @@
prefixIcon
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">
<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
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>
</Input>
</div>
@ -367,14 +358,14 @@
{#if isMultiSelectMode && media.length > 0}
<div class="bulk-actions">
<div class="bulk-actions-left">
<button
<button
onclick={selectAllMedia}
class="btn btn-secondary btn-small"
disabled={selectedMediaIds.size === media.length}
>
Select All ({media.length})
</button>
<button
<button
onclick={clearSelection}
class="btn btn-secondary btn-small"
disabled={selectedMediaIds.size === 0}
@ -384,26 +375,28 @@
</div>
<div class="bulk-actions-right">
{#if selectedMediaIds.size > 0}
<button
<button
onclick={handleBulkMarkPhotography}
class="btn btn-secondary btn-small"
title="Mark selected items as photography"
>
📸 Mark Photography
</button>
<button
<button
onclick={handleBulkUnmarkPhotography}
class="btn btn-secondary btn-small"
title="Remove photography status from selected items"
>
🚫 Remove Photography
</button>
<button
<button
onclick={handleBulkDelete}
class="btn btn-danger btn-small"
disabled={isDeleting}
>
{isDeleting ? 'Deleting...' : `Delete ${selectedMediaIds.size} file${selectedMediaIds.size > 1 ? 's' : ''}`}
{isDeleting
? 'Deleting...'
: `Delete ${selectedMediaIds.size} file${selectedMediaIds.size > 1 ? 's' : ''}`}
</button>
{/if}
</div>
@ -423,8 +416,8 @@
<div class="media-item-wrapper" class:multiselect={isMultiSelectMode}>
{#if isMultiSelectMode}
<div class="selection-checkbox">
<input
type="checkbox"
<input
type="checkbox"
checked={selectedMediaIds.has(item.id)}
onchange={() => toggleMediaSelection(item.id)}
id="media-{item.id}"
@ -432,15 +425,19 @@
<label for="media-{item.id}" class="checkbox-label"></label>
</div>
{/if}
<button
class="media-item"
<button
class="media-item"
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}"
class:selected={isMultiSelectMode && selectedMediaIds.has(item.id)}
>
{#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}
<div class="file-placeholder">
<span class="file-type">{getFileType(item.mimeType)}</span>
@ -451,8 +448,17 @@
<div class="media-indicators">
{#if item.isPhotography}
<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">
<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
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>
Photo
</span>
@ -462,9 +468,7 @@
Alt
</span>
{:else}
<span class="indicator-pill no-alt-text" title="No alt text">
No Alt
</span>
<span class="indicator-pill no-alt-text" title="No alt text"> No Alt </span>
{/if}
</div>
<span class="filesize">{formatFileSize(item.size)}</span>
@ -479,8 +483,8 @@
<div class="media-row-wrapper" class:multiselect={isMultiSelectMode}>
{#if isMultiSelectMode}
<div class="selection-checkbox">
<input
type="checkbox"
<input
type="checkbox"
checked={selectedMediaIds.has(item.id)}
onchange={() => toggleMediaSelection(item.id)}
id="media-row-{item.id}"
@ -488,16 +492,22 @@
<label for="media-row-{item.id}" class="checkbox-label"></label>
</div>
{/if}
<button
class="media-row"
<button
class="media-row"
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}"
class:selected={isMultiSelectMode && selectedMediaIds.has(item.id)}
>
<div class="media-preview">
{#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}
<div class="file-icon">{getFileType(item.mimeType)}</div>
{/if}
@ -508,8 +518,17 @@
<div class="media-indicators">
{#if item.isPhotography}
<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">
<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
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>
Photo
</span>
@ -519,9 +538,7 @@
Alt
</span>
{:else}
<span class="indicator-pill no-alt-text" title="No alt text">
No Alt
</span>
<span class="indicator-pill no-alt-text" title="No alt text"> No Alt </span>
{/if}
</div>
</div>
@ -541,8 +558,20 @@
</div>
<div class="media-indicator">
{#if !isMultiSelectMode}
<svg 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
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>
{/if}
</div>
@ -648,25 +677,6 @@
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 {
display: flex;
gap: $unit-2x;
@ -758,7 +768,7 @@
.file-type {
font-size: 0.875rem;
color: $grey-40;
}
}
}
.media-info {
@ -842,7 +852,7 @@
border-radius: $unit;
font-size: 0.75rem;
color: $grey-40;
}
}
}
.media-details {
@ -905,7 +915,7 @@
border: 1px solid $grey-80;
border-radius: 50px;
font-size: 0.75rem;
color: $grey-30;
color: $grey-30;
cursor: pointer;
transition: all 0.2s ease;
@ -999,7 +1009,7 @@
left: $unit;
z-index: 10;
input[type="checkbox"] {
input[type='checkbox'] {
opacity: 0;
position: absolute;
pointer-events: none;

View file

@ -2,8 +2,10 @@
import { onMount } from 'svelte'
import { goto } from '$app/navigation'
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 LoadingSpinner from '$lib/components/admin/LoadingSpinner.svelte'
import Select from '$lib/components/admin/Select.svelte'
interface Post {
id: number
@ -28,10 +30,17 @@
let error = $state('')
let total = $state(0)
let postTypeCounts = $state<Record<string, number>>({})
// Filter state
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> = {
post: '💭',
essay: '📝',
@ -88,7 +97,7 @@
post: 0,
essay: 0
}
posts.forEach((post) => {
// Normalize legacy types to simplified types
if (post.postType === 'blog') {
@ -115,15 +124,13 @@
if (selectedFilter === 'all') {
filteredPosts = posts
} else if (selectedFilter === 'post') {
filteredPosts = posts.filter(post =>
filteredPosts = posts.filter((post) =>
['post', 'microblog', 'link', 'photo'].includes(post.postType)
)
} else if (selectedFilter === 'essay') {
filteredPosts = posts.filter(post =>
['essay', 'blog'].includes(post.postType)
)
filteredPosts = posts.filter((post) => ['essay', 'blog'].includes(post.postType))
} else {
filteredPosts = posts.filter(post => post.postType === selectedFilter)
filteredPosts = posts.filter((post) => post.postType === selectedFilter)
}
}
@ -144,7 +151,7 @@
// Try to extract text from content JSON
if (post.content) {
let textContent = ''
if (typeof post.content === 'object' && post.content.content) {
// BlockNote/TipTap format
function extractText(node: any): string {
@ -166,7 +173,9 @@
// Fallback to link description for link posts
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
@ -186,8 +195,8 @@
} else if (diffDays < 7) {
return `${diffDays} days ago`
} else {
return date.toLocaleDateString('en-US', {
month: 'short',
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined
})
@ -196,7 +205,7 @@
function getDisplayTitle(post: Post): string {
if (post.title) return post.title
// For posts without titles, create a meaningful display title
if (post.linkUrl) {
try {
@ -206,46 +215,38 @@
return 'Link 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 `${postTypeLabels[post.postType] || post.postType}`
}
</script>
<AdminPage>
<header slot="header">
<h1>Universe</h1>
<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>
<AdminHeader title="Universe" slot="header">
{#snippet actions()}
<PostDropdown />
</div>
</header>
{/snippet}
</AdminHeader>
{#if error}
<div class="error-message">{error}</div>
{:else}
<!-- Stats -->
<div class="posts-stats">
<div class="stat">
<span class="stat-value">{postTypeCounts.all || 0}</span>
<span class="stat-label">Total posts</span>
</div>
<div class="stat">
<span class="stat-value">{postTypeCounts.post || 0}</span>
<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>
<!-- Filters -->
<div class="filters">
<Select
bind:value={selectedFilter}
options={filterOptions}
size="small"
variant="minimal"
onchange={handleFilterChange}
/>
</div>
<!-- Posts List -->
@ -288,12 +289,30 @@
<div class="post-content">
<h3 class="post-title">{getDisplayTitle(post)}</h3>
{#if post.linkUrl}
<div class="post-link">
<svg width="14" 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
width="14"
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>
<span class="link-url">{post.linkUrl}</span>
</div>
@ -318,8 +337,20 @@
<span class="edit-hint">Click to edit</span>
</div>
<div class="post-indicator">
<svg 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
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>
</div>
</div>
@ -333,40 +364,11 @@
<style lang="scss">
@import '$styles/variables.scss';
header {
.filters {
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;
.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;
min-width: 160px;
&:focus {
outline: none;
border-color: $grey-40;
}
}
align-items: center;
margin-bottom: $unit-4x;
}
.error-message {
@ -379,38 +381,6 @@
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 {
display: flex;
justify-content: center;
@ -642,4 +612,4 @@
align-self: flex-start;
}
}
</style>
</style>

View file

@ -2,8 +2,11 @@
import { onMount } from 'svelte'
import { goto } from '$app/navigation'
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 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 {
id: number
@ -19,11 +22,23 @@
}
let projects = $state<Project[]>([])
let filteredProjects = $state<Project[]>([])
let isLoading = $state(true)
let error = $state('')
let showDeleteModal = $state(false)
let projectToDelete = $state<Project | 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 () => {
await loadProjects()
@ -61,6 +76,17 @@
const data = await response.json()
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) {
error = 'Failed to load projects'
console.error(err)
@ -139,88 +165,91 @@
showDeleteModal = false
projectToDelete = null
}
function applyFilter() {
if (selectedFilter === 'all') {
filteredProjects = projects
} else {
filteredProjects = projects.filter((project) => project.status === selectedFilter)
}
}
function handleFilterChange() {
applyFilter()
}
</script>
<AdminPage>
<header slot="header">
<h1>Projects</h1>
<div class="header-actions">
<a href="/admin/projects/new" class="btn btn-primary">New Project</a>
</div>
</header>
<AdminHeader title="Projects" slot="header">
{#snippet actions()}
<Button variant="primary" size="large" href="/admin/projects/new">New Project</Button>
{/snippet}
</AdminHeader>
{#if error}
<div class="error">{error}</div>
{:else if isLoading}
<div class="loading">
<div class="spinner"></div>
<p>Loading projects...</p>
</div>
{:else if projects.length === 0}
<div class="empty-state">
<p>No projects found. Create your first project!</p>
</div>
{:else}
<div class="projects-list">
{#each projects as project}
<ProjectListItem
{project}
isDropdownActive={activeDropdown === project.id}
ontoggleDropdown={handleToggleDropdown}
onedit={handleEdit}
ontogglePublish={handleTogglePublish}
ondelete={handleDelete}
/>
{/each}
<!-- 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="spinner"></div>
<p>Loading projects...</p>
</div>
{:else if filteredProjects.length === 0}
<div class="empty-state">
<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>
{:else}
<div class="projects-list">
{#each filteredProjects as project}
<ProjectListItem
{project}
isDropdownActive={activeDropdown === project.id}
ontoggleDropdown={handleToggleDropdown}
onedit={handleEdit}
ontogglePublish={handleTogglePublish}
ondelete={handleDelete}
/>
{/each}
</div>
{/if}
{/if}
</AdminPage>
<DeleteConfirmationModal
bind:isOpen={showDeleteModal}
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}
onCancel={cancelDelete}
/>
<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 {
.filters {
display: flex;
gap: $unit-2x;
}
.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;
}
}
align-items: center;
margin-bottom: $unit-4x;
}
.error {
@ -246,7 +275,7 @@
p {
margin: 0;
}
}
}
@keyframes spin {
@ -262,7 +291,7 @@
p {
margin: 0;
}
}
}
.projects-list {