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 {
|
.page-header {
|
||||||
|
box-sizing: border-box;
|
||||||
|
min-height: 110px;
|
||||||
padding: $unit-4x;
|
padding: $unit-4x;
|
||||||
|
|
||||||
@include breakpoint('phone') {
|
@include breakpoint('phone') {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
@ -86,39 +82,49 @@
|
||||||
>
|
>
|
||||||
{#snippet icon()}
|
{#snippet icon()}
|
||||||
<div class="dropdown-icon">
|
<div class="dropdown-icon">
|
||||||
{#if type.value === 'essay'}
|
{#if type.value === 'essay'}
|
||||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||||
<path
|
<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"
|
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="currentColor"
|
||||||
stroke-width="1.5"
|
stroke-width="1.5"
|
||||||
/>
|
/>
|
||||||
<path d="M11 3V9H17" stroke="currentColor" stroke-width="1.5" />
|
<path d="M11 3V9H17" stroke="currentColor" stroke-width="1.5" />
|
||||||
<path
|
<path
|
||||||
d="M7 13H13"
|
d="M7 13H13"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
stroke-width="1.5"
|
stroke-width="1.5"
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
/>
|
/>
|
||||||
<path
|
<path
|
||||||
d="M7 10H13"
|
d="M7 10H13"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
stroke-width="1.5"
|
stroke-width="1.5"
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{:else if type.value === 'post'}
|
{:else if type.value === 'post'}
|
||||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||||
<path
|
<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"
|
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="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"
|
||||||
</svg>
|
stroke="currentColor"
|
||||||
{/if}
|
stroke-width="1.5"
|
||||||
</div>
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M5 9H10"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
<span class="dropdown-label">{type.label}</span>
|
<span class="dropdown-label">{type.label}</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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}
|
<select
|
||||||
<label class="select-label">
|
bind:value
|
||||||
{label}
|
class="select select-{size} select-{variant} {className}"
|
||||||
{#if required}
|
class:select-full-width={fullWidth}
|
||||||
<span class="required">*</span>
|
class:select-pill={pill}
|
||||||
{/if}
|
{...restProps}
|
||||||
</label>
|
>
|
||||||
{/if}
|
{#each options as option}
|
||||||
|
<option value={option.value}>{option.label}</option>
|
||||||
<div class="select-container" class:error>
|
{/each}
|
||||||
<select
|
</select>
|
||||||
bind:value
|
<div class="select-icon">
|
||||||
{disabled}
|
{@html ChevronDownIcon}
|
||||||
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>
|
</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;
|
||||||
border-color: $blue-50;
|
|
||||||
box-shadow: 0 0 0 3px rgba(20, 130, 193, 0.1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&:disabled {
|
&:disabled {
|
||||||
background: $grey-95;
|
|
||||||
color: $grey-60;
|
color: $grey-60;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.error {
|
// Default variant
|
||||||
border-color: $red-50;
|
&.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;
|
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>
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -758,7 +768,7 @@
|
||||||
.file-type {
|
.file-type {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: $grey-40;
|
color: $grey-40;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-info {
|
.media-info {
|
||||||
|
|
@ -842,7 +852,7 @@
|
||||||
border-radius: $unit;
|
border-radius: $unit;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: $grey-40;
|
color: $grey-40;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-details {
|
.media-details {
|
||||||
|
|
@ -905,7 +915,7 @@
|
||||||
border: 1px solid $grey-80;
|
border: 1px solid $grey-80;
|
||||||
border-radius: 50px;
|
border-radius: 50px;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: $grey-30;
|
color: $grey-30;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,88 +165,91 @@
|
||||||
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}
|
|
||||||
<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}
|
{:else}
|
||||||
<div class="projects-list">
|
<!-- Filters -->
|
||||||
{#each projects as project}
|
<div class="filters">
|
||||||
<ProjectListItem
|
<Select
|
||||||
{project}
|
bind:value={selectedFilter}
|
||||||
isDropdownActive={activeDropdown === project.id}
|
options={filterOptions}
|
||||||
ontoggleDropdown={handleToggleDropdown}
|
size="small"
|
||||||
onedit={handleEdit}
|
variant="minimal"
|
||||||
ontogglePublish={handleTogglePublish}
|
onchange={handleFilterChange}
|
||||||
ondelete={handleDelete}
|
/>
|
||||||
/>
|
|
||||||
{/each}
|
|
||||||
</div>
|
</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}
|
{/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 {
|
||||||
|
|
@ -246,7 +275,7 @@
|
||||||
|
|
||||||
p {
|
p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes spin {
|
@keyframes spin {
|
||||||
|
|
@ -262,7 +291,7 @@
|
||||||
|
|
||||||
p {
|
p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.projects-list {
|
.projects-list {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue