styles
This commit is contained in:
parent
2f504abb57
commit
5e066093d8
9 changed files with 559 additions and 531 deletions
3
src/assets/icons/chevron-down.svg
Normal file
3
src/assets/icons/chevron-down.svg
Normal 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 |
39
src/lib/components/admin/AdminHeader.svelte
Normal file
39
src/lib/components/admin/AdminHeader.svelte
Normal 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>
|
||||
|
|
@ -48,6 +48,8 @@
|
|||
}
|
||||
|
||||
.page-header {
|
||||
box-sizing: border-box;
|
||||
min-height: 110px;
|
||||
padding: $unit-4x;
|
||||
|
||||
@include breakpoint('phone') {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue