refactor: migrate admin UI to Svelte 5 runes

Convert admin components from Svelte 4 to Svelte 5 syntax using $props, $state, $derived, and $bindable runes. Simplifies AdminNavBar logic and improves type safety.
This commit is contained in:
Justin Edmund 2025-11-03 23:03:28 -08:00
parent 6ca6727eda
commit cf2842d22d
19 changed files with 306 additions and 320 deletions

View file

@ -24,7 +24,7 @@
width: 100%; width: 100%;
h1 { h1 {
font-size: 1.75rem; font-size: $font-size-large;
font-weight: 700; font-weight: 700;
margin: 0; margin: 0;
color: $gray-10; color: $gray-10;

View file

@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/stores' import { page } from '$app/stores'
import { onMount } from 'svelte'
import AvatarSimple from '$lib/components/AvatarSimple.svelte' import AvatarSimple from '$lib/components/AvatarSimple.svelte'
import WorkIcon from '$icons/work.svg?component' import WorkIcon from '$icons/work.svg?component'
import UniverseIcon from '$icons/universe.svg?component' import UniverseIcon from '$icons/universe.svg?component'
@ -8,20 +7,6 @@
import AlbumIcon from '$icons/album.svg?component' import AlbumIcon from '$icons/album.svg?component'
const currentPath = $derived($page.url.pathname) const currentPath = $derived($page.url.pathname)
let isScrolled = $state(false)
onMount(() => {
const handleScroll = () => {
isScrolled = window.scrollY > 0
}
window.addEventListener('scroll', handleScroll)
handleScroll() // Check initial scroll position
return () => {
window.removeEventListener('scroll', handleScroll)
}
})
interface NavItem { interface NavItem {
text: string text: string
@ -50,166 +35,97 @@
) )
</script> </script>
<nav class="admin-nav-bar" class:scrolled={isScrolled}> <nav class="admin-nav-rail">
<div class="nav-container"> <a href="/" class="nav-brand">
<div class="nav-content"> <div class="brand-logo">
<a href="/" class="nav-brand"> <AvatarSimple />
<div class="brand-logo">
<AvatarSimple />
</div>
<span class="brand-text">Back to jedmund.com</span>
</a>
<div class="nav-links">
{#each navItems as item, index}
<a href={item.href} class="nav-link" class:active={index === activeIndex}>
<item.icon class="nav-icon" />
<span class="nav-text">{item.text}</span>
</a>
{/each}
</div>
</div> </div>
</a>
<div class="nav-links">
{#each navItems as item, index}
<a href={item.href} class="nav-link" class:active={index === activeIndex}>
<item.icon class="nav-icon" />
<span class="nav-text">{item.text}</span>
</a>
{/each}
</div> </div>
</nav> </nav>
<style lang="scss"> <style lang="scss">
// Breakpoint variables .admin-nav-rail {
$phone-max: 639px;
$tablet-min: 640px;
$tablet-max: 1023px;
$laptop-min: 1024px;
$laptop-max: 1439px;
$monitor-min: 1440px;
.admin-nav-bar {
position: sticky; position: sticky;
top: 0; top: 0;
z-index: $z-index-admin-nav; align-self: flex-start;
width: 100%; width: 80px;
min-width: 80px;
height: 100vh;
background: $bg-color; background: $bg-color;
border-bottom: 1px solid transparent; border-right: 1px solid $gray-80;
transition: border-bottom 0.2s ease;
&.scrolled {
border-bottom: 1px solid $gray-60;
}
}
.nav-container {
width: 100%;
padding: 0 $unit-3x;
// Phone: Full width with padding
@media (max-width: $phone-max) {
padding: 0 $unit-2x;
}
// Tablet: Constrained width
@media (min-width: $tablet-min) and (max-width: $tablet-max) {
max-width: 768px;
margin: 0 auto;
padding: 0 $unit-4x;
}
// Laptop: Wider constrained width
@media (min-width: $laptop-min) and (max-width: $laptop-max) {
max-width: 900px;
margin: 0 auto;
padding: 0 $unit-5x;
}
// Monitor: Maximum constrained width
@media (min-width: $monitor-min) {
max-width: 900px;
margin: 0 auto;
padding: 0 $unit-6x;
}
}
.nav-content {
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
justify-content: space-between; padding: $unit $unit-2x;
height: 64px; gap: $unit-half;
gap: $unit-4x;
@media (max-width: $phone-max) {
height: 56px;
gap: $unit-2x;
}
} }
.nav-brand { .nav-brand {
display: flex; display: flex;
align-items: center; align-items: center;
gap: $unit; justify-content: center;
text-decoration: none; text-decoration: none;
color: $gray-30; padding: $unit-2x $unit-half;
font-weight: 400; border-radius: $corner-radius-2xl;
font-size: 0.925rem; transition: background-color 0.2s ease;
transition: color 0.2s ease; width: 100%;
&:hover { &:hover {
color: $gray-20; background-color: $gray-70;
} }
.brand-logo { .brand-logo {
height: 32px; height: 40px;
width: 32px; width: 40px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex-shrink: 0; flex-shrink: 0;
:global(.face-container) { :global(.face-container) {
--face-size: 32px; --face-size: 40px;
width: 32px; width: 40px;
height: 32px; height: 40px;
} }
:global(svg) { :global(svg) {
width: 32px; width: 40px;
height: 32px; height: 40px;
}
}
.brand-text {
white-space: nowrap;
@media (max-width: $phone-max) {
display: none;
} }
} }
} }
.nav-links { .nav-links {
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
gap: $unit; gap: $unit-half;
flex: 1; width: 100%;
justify-content: right;
@media (max-width: $phone-max) {
gap: 0;
}
} }
.nav-link { .nav-link {
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
gap: $unit; justify-content: center;
padding: $unit $unit-2x; gap: $unit-half;
border-radius: $card-corner-radius; padding: $unit-2x $unit-half;
border-radius: $corner-radius-2xl;
text-decoration: none; text-decoration: none;
font-size: 0.925rem; font-size: 0.75rem;
font-weight: 500; font-weight: 500;
color: $gray-30; color: $gray-30;
transition: all 0.2s ease; transition: all 0.2s ease;
position: relative; width: 100%;
@media (max-width: $phone-max) {
padding: $unit-2x $unit;
}
&:hover { &:hover {
background-color: $gray-70; background-color: $gray-70;
@ -221,22 +137,22 @@
} }
.nav-icon { .nav-icon {
font-size: 1.1rem; font-size: 1.5rem;
line-height: 1; line-height: 1;
display: flex;
align-items: center;
justify-content: center;
@media (max-width: $tablet-max) { :global(svg) {
font-size: 1rem; width: 24px;
height: 24px;
} }
} }
.nav-text { .nav-text {
@media (max-width: $phone-max) { text-align: center;
display: none; white-space: nowrap;
} line-height: 1.2;
} }
} }
.nav-actions {
// Placeholder for future actions if needed
}
</style> </style>

View file

@ -30,11 +30,11 @@
} }
.segment { .segment {
padding: $unit $unit-3x; padding: $unit $unit-2x;
background: transparent; background: transparent;
border: none; border: none;
border-radius: 50px; border-radius: 50px;
font-size: 0.925rem; font-size: 0.875rem;
color: $gray-40; color: $gray-40;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: all 0.2s ease;

View file

@ -5,7 +5,8 @@
import AdminSegmentedControl from './AdminSegmentedControl.svelte' import AdminSegmentedControl from './AdminSegmentedControl.svelte'
import Input from './Input.svelte' import Input from './Input.svelte'
import Button from './Button.svelte' import Button from './Button.svelte'
import StatusDropdown from './StatusDropdown.svelte' import DropdownSelectField from './DropdownSelectField.svelte'
import AutoSaveStatus from './AutoSaveStatus.svelte'
import UnifiedMediaModal from './UnifiedMediaModal.svelte' import UnifiedMediaModal from './UnifiedMediaModal.svelte'
import SmartImage from '../SmartImage.svelte' import SmartImage from '../SmartImage.svelte'
import Composer from './composer' import Composer from './composer'
@ -46,6 +47,19 @@
{ value: 'content', label: 'Content' } { value: 'content', label: 'Content' }
] ]
const statusOptions = [
{
value: 'draft',
label: 'Draft',
description: 'Only visible to you'
},
{
value: 'published',
label: 'Published',
description: 'Visible on your public site'
}
]
// Form data // Form data
let formData = $state({ let formData = $state({
title: '', title: '',
@ -231,11 +245,6 @@
} }
} }
async function handleStatusChange(newStatus: string) {
formData.status = newStatus as any
await handleSave()
}
async function handleBulkAlbumSave() { async function handleBulkAlbumSave() {
// Reload album to get updated photo count // Reload album to get updated photo count
if (album && mode === 'edit') { if (album && mode === 'edit') {
@ -255,17 +264,7 @@
<AdminPage> <AdminPage>
<header slot="header"> <header slot="header">
<div class="header-left"> <div class="header-left">
<button class="btn-icon" onclick={() => goto('/admin/albums')} aria-label="Back to albums"> <h1 class="form-title">{formData.title || 'Untitled Album'}</h1>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path
d="M12.5 15L7.5 10L12.5 5"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
</div> </div>
<div class="header-center"> <div class="header-center">
<AdminSegmentedControl <AdminSegmentedControl
@ -276,18 +275,9 @@
</div> </div>
<div class="header-actions"> <div class="header-actions">
{#if !isLoading} {#if !isLoading}
<StatusDropdown <AutoSaveStatus
currentStatus={formData.status} status="idle"
onStatusChange={handleStatusChange} lastSavedAt={album?.updatedAt}
disabled={isSaving || (mode === 'create' && (!formData.title || !formData.slug))}
isLoading={isSaving}
primaryAction={formData.status === 'published'
? { label: 'Save', status: 'published' }
: { label: 'Publish', status: 'published' }}
dropdownActions={[
{ label: 'Save as Draft', status: 'draft', show: formData.status !== 'draft' }
]}
viewUrl={album?.slug ? `/albums/${album.slug}` : undefined}
/> />
{/if} {/if}
</div> </div>
@ -338,6 +328,13 @@
disabled={isSaving} disabled={isSaving}
/> />
</div> </div>
<DropdownSelectField
label="Status"
bind:value={formData.status}
options={statusOptions}
disabled={isSaving}
/>
</div> </div>
<!-- Display Settings --> <!-- Display Settings -->
@ -455,6 +452,16 @@
} }
} }
.form-title {
margin: 0;
font-size: 1rem;
font-weight: 500;
color: $gray-20;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.btn-icon { .btn-icon {
width: 40px; width: 40px;
height: 40px; height: 40px;

View file

@ -1,19 +1,31 @@
<script lang="ts"> <script lang="ts">
import type { AutoSaveStatus } from '$lib/admin/autoSave' import type { AutoSaveStatus } from '$lib/admin/autoSave'
import { formatTimeAgo } from '$lib/utils/time'
interface Props { interface Props {
statusStore?: { subscribe: (run: (v: AutoSaveStatus) => void) => () => void } statusStore?: { subscribe: (run: (v: AutoSaveStatus) => void) => () => void }
errorStore?: { subscribe: (run: (v: string | null) => void) => () => void } errorStore?: { subscribe: (run: (v: string | null) => void) => () => void }
status?: AutoSaveStatus status?: AutoSaveStatus
error?: string | null error?: string | null
lastSavedAt?: Date | string | null
showTimestamp?: boolean
compact?: boolean compact?: boolean
} }
let { statusStore, errorStore, status: statusProp, error: errorProp, compact = true }: Props = $props() let {
statusStore,
errorStore,
status: statusProp,
error: errorProp,
lastSavedAt,
showTimestamp = true,
compact = true
}: Props = $props()
// Support both old subscription-based stores and new reactive values // Support both old subscription-based stores and new reactive values
let status = $state<AutoSaveStatus>('idle') let status = $state<AutoSaveStatus>('idle')
let errorText = $state<string | null>(null) let errorText = $state<string | null>(null)
let refreshKey = $state(0) // Used to force re-render for time updates
$effect(() => { $effect(() => {
// If using direct props (new runes-based store) // If using direct props (new runes-based store)
@ -35,17 +47,33 @@
} }
}) })
// Auto-refresh timestamp every 30 seconds
$effect(() => {
if (!lastSavedAt || !showTimestamp) return
const interval = setInterval(() => {
refreshKey++
}, 30000)
return () => clearInterval(interval)
})
const label = $derived.by(() => { const label = $derived.by(() => {
// Force dependency on refreshKey to trigger re-computation
refreshKey
switch (status) { switch (status) {
case 'saving': case 'saving':
return 'Saving…' return 'Saving…'
case 'saved': case 'saved':
return 'All changes saved' case 'idle':
return lastSavedAt && showTimestamp
? `Saved ${formatTimeAgo(lastSavedAt)}`
: 'All changes saved'
case 'offline': case 'offline':
return 'Offline' return 'Offline'
case 'error': case 'error':
return errorText ? `Error — ${errorText}` : 'Save failed' return errorText ? `Error — ${errorText}` : 'Save failed'
case 'idle':
default: default:
return '' return ''
} }

View file

@ -3,9 +3,18 @@
onclick?: (event: MouseEvent) => void onclick?: (event: MouseEvent) => void
variant?: 'default' | 'danger' variant?: 'default' | 'danger'
disabled?: boolean disabled?: boolean
label?: string
description?: string
} }
let { onclick, variant = 'default', disabled = false, children }: Props = $props() let {
onclick,
variant = 'default',
disabled = false,
label,
description,
children
}: Props = $props()
function handleClick(event: MouseEvent) { function handleClick(event: MouseEvent) {
if (disabled) return if (disabled) return
@ -18,10 +27,20 @@
class="dropdown-item" class="dropdown-item"
class:danger={variant === 'danger'} class:danger={variant === 'danger'}
class:disabled class:disabled
class:has-description={!!description}
{disabled} {disabled}
onclick={handleClick} onclick={handleClick}
> >
{@render children()} {#if label}
<div class="dropdown-item-content">
<div class="dropdown-item-label">{label}</div>
{#if description}
<div class="dropdown-item-description">{description}</div>
{/if}
</div>
{:else}
{@render children()}
{/if}
</button> </button>
<style lang="scss"> <style lang="scss">
@ -38,12 +57,20 @@
cursor: pointer; cursor: pointer;
transition: background-color 0.2s ease; transition: background-color 0.2s ease;
&.has-description {
padding: $unit-2x $unit-3x;
}
&:hover:not(:disabled) { &:hover:not(:disabled) {
background-color: $gray-95; background-color: $gray-95;
} }
&.danger { &.danger {
color: $red-60; color: $red-60;
.dropdown-item-label {
color: $red-60;
}
} }
&:disabled { &:disabled {
@ -51,4 +78,23 @@
cursor: not-allowed; cursor: not-allowed;
} }
} }
.dropdown-item-content {
display: flex;
flex-direction: column;
gap: 2px;
}
.dropdown-item-label {
font-size: 0.875rem;
color: $gray-10;
font-weight: 500;
line-height: 1.4;
}
.dropdown-item-description {
font-size: 0.75rem;
color: $gray-40;
line-height: 1.3;
}
</style> </style>

View file

@ -5,11 +5,11 @@
import Editor from './Editor.svelte' import Editor from './Editor.svelte'
import Button from './Button.svelte' import Button from './Button.svelte'
import Input from './Input.svelte' import Input from './Input.svelte'
import DropdownSelectField from './DropdownSelectField.svelte'
import { toast } from '$lib/stores/toast' import { toast } from '$lib/stores/toast'
import { makeDraftKey, saveDraft, loadDraft, clearDraft, timeAgo } from '$lib/admin/draftStore' import { makeDraftKey, saveDraft, loadDraft, clearDraft, timeAgo } from '$lib/admin/draftStore'
import { createAutoSaveStore } from '$lib/admin/autoSave.svelte' import { createAutoSaveStore } from '$lib/admin/autoSave.svelte'
import AutoSaveStatus from './AutoSaveStatus.svelte' import AutoSaveStatus from './AutoSaveStatus.svelte'
import { clickOutside } from '$lib/actions/clickOutside'
import type { JSONContent } from '@tiptap/core' import type { JSONContent } from '@tiptap/core'
interface Props { interface Props {
@ -32,7 +32,6 @@
let hasLoaded = $state(mode === 'create') // Create mode loads immediately let hasLoaded = $state(mode === 'create') // Create mode loads immediately
let isSaving = $state(false) let isSaving = $state(false)
let activeTab = $state('metadata') let activeTab = $state('metadata')
let showPublishMenu = $state(false)
let updatedAt = $state<string | undefined>(initialData?.updatedAt) let updatedAt = $state<string | undefined>(initialData?.updatedAt)
// Form data // Form data
@ -94,6 +93,19 @@ let autoSave = mode === 'edit' && postId
{ value: 'content', label: 'Content' } { value: 'content', label: 'Content' }
] ]
const statusOptions = [
{
value: 'draft',
label: 'Draft',
description: 'Only visible to you'
},
{
value: 'published',
label: 'Published',
description: 'Visible on your public site'
}
]
// Auto-generate slug from title // Auto-generate slug from title
$effect(() => { $effect(() => {
if (title && !slug) { if (title && !slug) {
@ -300,41 +312,12 @@ $effect(() => {
} }
} }
async function handlePublish() {
status = 'published'
await handleSave()
showPublishMenu = false
}
async function handleUnpublish() {
status = 'draft'
await handleSave()
showPublishMenu = false
}
function togglePublishMenu() {
showPublishMenu = !showPublishMenu
}
function handleClickOutsideMenu() {
showPublishMenu = false
}
</script> </script>
<AdminPage> <AdminPage>
<header slot="header"> <header slot="header">
<div class="header-left"> <div class="header-left">
<Button variant="ghost" iconOnly onclick={() => goto('/admin/posts')}> <h1 class="form-title">{title || 'Untitled Essay'}</h1>
<svg slot="icon" width="20" height="20" viewBox="0 0 20 20" fill="none">
<path
d="M12.5 15L7.5 10L12.5 5"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</Button>
</div> </div>
<div class="header-center"> <div class="header-center">
<AdminSegmentedControl <AdminSegmentedControl
@ -344,56 +327,12 @@ $effect(() => {
/> />
</div> </div>
<div class="header-actions"> <div class="header-actions">
<div
class="save-actions"
use:clickOutside={{ enabled: showPublishMenu }}
onclickoutside={handleClickOutsideMenu}
>
<Button variant="primary" onclick={handleSave} disabled={isSaving} class="save-button">
{status === 'published' ? 'Save' : 'Save Draft'}
</Button>
<Button
variant="primary"
iconOnly
buttonSize="medium"
active={showPublishMenu}
onclick={togglePublishMenu}
disabled={isSaving}
class="chevron-button"
>
<svg
slot="icon"
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3 4.5L6 7.5L9 4.5"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</Button>
{#if showPublishMenu}
<div class="publish-menu">
{#if status === 'published'}
<Button variant="ghost" onclick={handleUnpublish} class="menu-item" fullWidth>
Unpublish
</Button>
{:else}
<Button variant="ghost" onclick={handlePublish} class="menu-item" fullWidth>
Publish
</Button>
{/if}
</div>
{/if}
</div>
{#if mode === 'edit' && autoSave} {#if mode === 'edit' && autoSave}
<AutoSaveStatus status={autoSave.status} error={autoSave.lastError} /> <AutoSaveStatus
status={autoSave.status}
error={autoSave.lastError}
lastSavedAt={initialData?.updatedAt}
/>
{/if} {/if}
</div> </div>
</header> </header>
@ -442,6 +381,12 @@ $effect(() => {
<Input label="Slug" bind:value={slug} placeholder="essay-url-slug" /> <Input label="Slug" bind:value={slug} placeholder="essay-url-slug" />
<DropdownSelectField
label="Status"
bind:value={status}
options={statusOptions}
/>
<div class="tags-field"> <div class="tags-field">
<label class="input-label">Tags</label> <label class="input-label">Tags</label>
<div class="tag-input-wrapper"> <div class="tag-input-wrapper">
@ -522,6 +467,16 @@ $effect(() => {
} }
} }
.form-title {
margin: 0;
font-size: 1rem;
font-weight: 500;
color: $gray-20;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-container { .admin-container {
width: 100%; width: 100%;
margin: 0 auto; margin: 0 auto;

View file

@ -58,11 +58,11 @@
let charsRemaining = $derived(maxLength ? maxLength - charCount : 0) let charsRemaining = $derived(maxLength ? maxLength - charCount : 0)
// Color swatch validation and display // Color swatch validation and display
const isValidHexColor = $derived(() => { function isValidHexColor() {
if (!colorSwatch || !value) return false if (!colorSwatch || !value) return false
const hexRegex = /^#[0-9A-Fa-f]{6}$/ const hexRegex = /^#[0-9A-Fa-f]{6}$/
return hexRegex.test(String(value)) return hexRegex.test(String(value))
}) }
// Color picker functionality // Color picker functionality
let colorPickerInput: HTMLInputElement let colorPickerInput: HTMLInputElement
@ -81,7 +81,7 @@
} }
// Compute classes // Compute classes
const wrapperClasses = $derived(() => { function wrapperClasses() {
const classes = ['input-wrapper'] const classes = ['input-wrapper']
if (size) classes.push(`input-wrapper-${size}`) if (size) classes.push(`input-wrapper-${size}`)
if (fullWidth) classes.push('full-width') if (fullWidth) classes.push('full-width')
@ -93,15 +93,15 @@
if (wrapperClass) classes.push(wrapperClass) if (wrapperClass) classes.push(wrapperClass)
if (className) classes.push(className) if (className) classes.push(className)
return classes.join(' ') return classes.join(' ')
}) }
const inputClasses = $derived(() => { function inputClasses() {
const classes = ['input'] const classes = ['input']
classes.push(`input-${size}`) classes.push(`input-${size}`)
if (pill) classes.push('input-pill') if (pill) classes.push('input-pill')
if (inputClass) classes.push(inputClass) if (inputClass) classes.push(inputClass)
return classes.join(' ') return classes.join(' ')
}) }
</script> </script>
<div class={wrapperClasses()}> <div class={wrapperClasses()}>
@ -121,7 +121,7 @@
</span> </span>
{/if} {/if}
{#if colorSwatch && isValidHexColor} {#if colorSwatch && isValidHexColor()}
<span <span
class="color-swatch" class="color-swatch"
style="background-color: {value}" style="background-color: {value}"
@ -154,7 +154,7 @@
<input <input
bind:this={colorPickerInput} bind:this={colorPickerInput}
type="color" type="color"
value={isValidHexColor ? String(value) : '#000000'} value={isValidHexColor() ? String(value) : '#000000'}
oninput={handleColorPickerChange} oninput={handleColorPickerChange}
onchange={handleColorPickerChange} onchange={handleColorPickerChange}
style="position: absolute; visibility: hidden; pointer-events: none;" style="position: absolute; visibility: hidden; pointer-events: none;"

View file

@ -47,7 +47,7 @@
<Button <Button
bind:this={buttonRef} bind:this={buttonRef}
variant="primary" variant="primary"
buttonSize="large" buttonSize="medium"
onclick={(e) => { onclick={(e) => {
e.stopPropagation() e.stopPropagation()
isOpen = !isOpen isOpen = !isOpen

View file

@ -7,7 +7,6 @@
import ProjectMetadataForm from './ProjectMetadataForm.svelte' import ProjectMetadataForm from './ProjectMetadataForm.svelte'
import ProjectBrandingForm from './ProjectBrandingForm.svelte' import ProjectBrandingForm from './ProjectBrandingForm.svelte'
import ProjectImagesForm from './ProjectImagesForm.svelte' import ProjectImagesForm from './ProjectImagesForm.svelte'
import StatusDropdown from './StatusDropdown.svelte'
import AutoSaveStatus from './AutoSaveStatus.svelte' import AutoSaveStatus from './AutoSaveStatus.svelte'
import DraftPrompt from './DraftPrompt.svelte' import DraftPrompt from './DraftPrompt.svelte'
import { toast } from '$lib/stores/toast' import { toast } from '$lib/stores/toast'
@ -71,6 +70,7 @@
const tabOptions = [ const tabOptions = [
{ value: 'metadata', label: 'Metadata' }, { value: 'metadata', label: 'Metadata' },
{ value: 'branding', label: 'Branding' },
{ value: 'case-study', label: 'Case Study' } { value: 'case-study', label: 'Case Study' }
] ]
@ -169,26 +169,13 @@
} }
} }
async function handleStatusChange(newStatus: string) {
formStore.setField('status', newStatus)
await handleSave()
}
</script> </script>
<AdminPage> <AdminPage>
<header slot="header"> <header slot="header">
<div class="header-left"> <div class="header-left">
<button class="btn-icon" onclick={() => goto('/admin/projects')}> <h1 class="form-title">{formStore.fields.title || 'Untitled Project'}</h1>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path
d="M12.5 15L7.5 10L12.5 5"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
</div> </div>
<div class="header-center"> <div class="header-center">
<AdminSegmentedControl <AdminSegmentedControl
@ -198,29 +185,12 @@
/> />
</div> </div>
<div class="header-actions"> <div class="header-actions">
{#if !isLoading} {#if !isLoading && mode === 'edit' && autoSave}
<StatusDropdown <AutoSaveStatus
currentStatus={formStore.fields.status} status={autoSave.status}
onStatusChange={handleStatusChange} error={autoSave.lastError}
disabled={isSaving} lastSavedAt={project?.updatedAt}
isLoading={isSaving}
primaryAction={formStore.fields.status === 'published'
? { label: 'Save', status: 'published' }
: { label: 'Publish', status: 'published' }}
dropdownActions={[
{ label: 'Save as Draft', status: 'draft', show: formStore.fields.status !== 'draft' },
{ label: 'List Only', status: 'list-only', show: formStore.fields.status !== 'list-only' },
{
label: 'Password Protected',
status: 'password-protected',
show: formStore.fields.status !== 'password-protected'
}
]}
viewUrl={project?.slug ? `/work/${project.slug}` : undefined}
/> />
{#if mode === 'edit' && autoSave}
<AutoSaveStatus status={autoSave.status} error={autoSave.lastError} />
{/if}
{/if} {/if}
</div> </div>
</header> </header>
@ -256,8 +226,20 @@
}} }}
> >
<ProjectMetadataForm bind:formData={formStore.fields} validationErrors={formStore.validationErrors} onSave={handleSave} /> <ProjectMetadataForm bind:formData={formStore.fields} validationErrors={formStore.validationErrors} onSave={handleSave} />
</form>
</div>
</div>
<!-- Branding Panel -->
<div class="panel content-wrapper" class:active={activeTab === 'branding'}>
<div class="form-content">
<form
onsubmit={(e) => {
e.preventDefault()
handleSave()
}}
>
<ProjectBrandingForm bind:formData={formStore.fields} validationErrors={formStore.validationErrors} onSave={handleSave} /> <ProjectBrandingForm bind:formData={formStore.fields} validationErrors={formStore.validationErrors} onSave={handleSave} />
<ProjectImagesForm bind:formData={formStore.fields} validationErrors={formStore.validationErrors} onSave={handleSave} />
</form> </form>
</div> </div>
</div> </div>
@ -308,6 +290,16 @@
} }
} }
.form-title {
margin: 0;
font-size: 1rem;
font-weight: 500;
color: $gray-20;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.btn-icon { .btn-icon {
width: 40px; width: 40px;
height: 40px; height: 40px;

View file

@ -3,6 +3,7 @@
import Textarea from './Textarea.svelte' import Textarea from './Textarea.svelte'
import SelectField from './SelectField.svelte' import SelectField from './SelectField.svelte'
import SegmentedControlField from './SegmentedControlField.svelte' import SegmentedControlField from './SegmentedControlField.svelte'
import DropdownSelectField from './DropdownSelectField.svelte'
import type { ProjectFormData } from '$lib/types/project' import type { ProjectFormData } from '$lib/types/project'
interface Props { interface Props {
@ -12,6 +13,29 @@
} }
let { formData = $bindable(), validationErrors, onSave }: Props = $props() let { formData = $bindable(), validationErrors, onSave }: Props = $props()
const statusOptions = [
{
value: 'draft',
label: 'Draft',
description: 'Only visible to you'
},
{
value: 'published',
label: 'Published',
description: 'Visible on your public site'
},
{
value: 'list-only',
label: 'List Only',
description: 'Shows in lists but detail page is hidden'
},
{
value: 'password-protected',
label: 'Password Protected',
description: 'Requires password to view'
}
]
</script> </script>
<div class="form-section"> <div class="form-section">
@ -34,14 +58,22 @@
/> />
<Input <Input
type="url" size="jumbo"
label="External URL" label="External URL"
type="url"
error={validationErrors.externalUrl} error={validationErrors.externalUrl}
bind:value={formData.externalUrl} bind:value={formData.externalUrl}
placeholder="https://example.com" placeholder="https://example.com"
/> />
<div class="form-row three-column"> <div class="form-row two-column">
<DropdownSelectField
label="Status"
bind:value={formData.status}
options={statusOptions}
error={validationErrors.status}
/>
<SegmentedControlField <SegmentedControlField
label="Project Type" label="Project Type"
bind:value={formData.projectType} bind:value={formData.projectType}
@ -51,10 +83,13 @@
{ value: 'labs', label: 'Labs' } { value: 'labs', label: 'Labs' }
]} ]}
/> />
</div>
<div class="form-row two-column">
<Input <Input
type="number" type="number"
label="Year" label="Year"
size="jumbo"
required required
error={validationErrors.year} error={validationErrors.year}
bind:value={formData.year} bind:value={formData.year}
@ -64,6 +99,7 @@
<Input <Input
label="Client" label="Client"
size="jumbo"
error={validationErrors.client} error={validationErrors.client}
bind:value={formData.client} bind:value={formData.client}
placeholder="Client or company name" placeholder="Client or company name"

View file

@ -41,7 +41,7 @@
{#snippet trigger()} {#snippet trigger()}
<Button <Button
variant="primary" variant="primary"
buttonSize="large" buttonSize="medium"
onclick={handlePublishClick} onclick={handlePublishClick}
disabled={disabled || isLoading} disabled={disabled || isLoading}
> >

View file

@ -47,12 +47,12 @@
{isLoading} {isLoading}
/> />
{:else if status === 'published'} {:else if status === 'published'}
<Button variant="primary" buttonSize="large" onclick={handleSave} disabled={isDisabled}> <Button variant="primary" buttonSize="medium" onclick={handleSave} disabled={isDisabled}>
{isLoading ? 'Saving...' : 'Save'} {isLoading ? 'Saving...' : 'Save'}
</Button> </Button>
{:else} {:else}
<!-- For other statuses like 'list-only', 'password-protected', etc. --> <!-- For other statuses like 'list-only', 'password-protected', etc. -->
<Button variant="primary" buttonSize="large" onclick={handleSave} disabled={isDisabled}> <Button variant="primary" buttonSize="medium" onclick={handleSave} disabled={isDisabled}>
{isLoading ? 'Saving...' : 'Save'} {isLoading ? 'Saving...' : 'Save'}
</Button> </Button>
{/if} {/if}

View file

@ -53,7 +53,7 @@
{#snippet trigger()} {#snippet trigger()}
<Button <Button
variant="primary" variant="primary"
buttonSize="large" buttonSize="medium"
onclick={handlePrimaryAction} onclick={handlePrimaryAction}
disabled={disabled || isLoading} disabled={disabled || isLoading}
> >

View file

@ -47,14 +47,20 @@
} }
.admin-container { .admin-container {
min-height: 100vh; height: 100vh;
overflow: hidden;
display: flex; display: flex;
flex-direction: column; flex-direction: row;
background-color: $bg-color; background-color: $bg-color;
} }
.admin-content { .admin-content {
flex: 1; flex: 1;
display: flex;
flex-direction: column;
padding-top: $unit;
padding-right: $unit;
padding-bottom: $unit;
} }
.admin-card-layout { .admin-card-layout {
@ -63,7 +69,7 @@
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: flex-start; align-items: flex-start;
padding: $unit-6x $unit-4x; padding: 0;
min-height: calc(100vh - 60px); // Account for navbar height: 100vh;
} }
</style> </style>

View file

@ -275,7 +275,7 @@
<AdminPage> <AdminPage>
<AdminHeader title="Albums" slot="header"> <AdminHeader title="Albums" slot="header">
{#snippet actions()} {#snippet actions()}
<Button variant="primary" buttonSize="large" onclick={handleNewAlbum}>New Album</Button> <Button variant="primary" buttonSize="medium" onclick={handleNewAlbum}>New Album</Button>
{/snippet} {/snippet}
</AdminHeader> </AdminHeader>

View file

@ -338,8 +338,8 @@
<AdminHeader title="Media Library" slot="header"> <AdminHeader title="Media Library" slot="header">
{#snippet actions()} {#snippet actions()}
<div class="actions-dropdown"> <div class="actions-dropdown">
<Button variant="primary" buttonSize="large" onclick={openUploadModal}>Upload</Button> <Button variant="primary" buttonSize="medium" onclick={openUploadModal}>Upload</Button>
<Button variant="ghost" iconOnly buttonSize="large" onclick={handleDropdownToggle}> <Button variant="ghost" iconOnly buttonSize="medium" onclick={handleDropdownToggle}>
{#snippet icon()} {#snippet icon()}
<ChevronDown /> <ChevronDown />
{/snippet} {/snippet}

View file

@ -120,7 +120,7 @@ const statusFilterOptions = [
<AdminPage> <AdminPage>
<AdminHeader title="Universe" slot="header"> <AdminHeader title="Universe" slot="header">
{#snippet actions()} {#snippet actions()}
<Button variant="primary" buttonSize="large" onclick={handleNewEssay}> <Button variant="primary" buttonSize="medium" onclick={handleNewEssay}>
New Essay New Essay
</Button> </Button>
{/snippet} {/snippet}

View file

@ -116,7 +116,7 @@
{#snippet actions()} {#snippet actions()}
<Button <Button
variant="primary" variant="primary"
buttonSize="large" buttonSize="medium"
onclick={() => goto('/admin/projects/new')} onclick={() => goto('/admin/projects/new')}
> >
New project New project