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%;
h1 {
font-size: 1.75rem;
font-size: $font-size-large;
font-weight: 700;
margin: 0;
color: $gray-10;

View file

@ -1,6 +1,5 @@
<script lang="ts">
import { page } from '$app/stores'
import { onMount } from 'svelte'
import AvatarSimple from '$lib/components/AvatarSimple.svelte'
import WorkIcon from '$icons/work.svg?component'
import UniverseIcon from '$icons/universe.svg?component'
@ -8,20 +7,6 @@
import AlbumIcon from '$icons/album.svg?component'
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 {
text: string
@ -50,166 +35,97 @@
)
</script>
<nav class="admin-nav-bar" class:scrolled={isScrolled}>
<div class="nav-container">
<div class="nav-content">
<a href="/" class="nav-brand">
<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>
<nav class="admin-nav-rail">
<a href="/" class="nav-brand">
<div class="brand-logo">
<AvatarSimple />
</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>
</nav>
<style lang="scss">
// Breakpoint variables
$phone-max: 639px;
$tablet-min: 640px;
$tablet-max: 1023px;
$laptop-min: 1024px;
$laptop-max: 1439px;
$monitor-min: 1440px;
.admin-nav-bar {
.admin-nav-rail {
position: sticky;
top: 0;
z-index: $z-index-admin-nav;
width: 100%;
align-self: flex-start;
width: 80px;
min-width: 80px;
height: 100vh;
background: $bg-color;
border-bottom: 1px solid transparent;
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 {
border-right: 1px solid $gray-80;
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
height: 64px;
gap: $unit-4x;
@media (max-width: $phone-max) {
height: 56px;
gap: $unit-2x;
}
padding: $unit $unit-2x;
gap: $unit-half;
}
.nav-brand {
display: flex;
align-items: center;
gap: $unit;
justify-content: center;
text-decoration: none;
color: $gray-30;
font-weight: 400;
font-size: 0.925rem;
transition: color 0.2s ease;
padding: $unit-2x $unit-half;
border-radius: $corner-radius-2xl;
transition: background-color 0.2s ease;
width: 100%;
&:hover {
color: $gray-20;
background-color: $gray-70;
}
.brand-logo {
height: 32px;
width: 32px;
height: 40px;
width: 40px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
:global(.face-container) {
--face-size: 32px;
width: 32px;
height: 32px;
--face-size: 40px;
width: 40px;
height: 40px;
}
:global(svg) {
width: 32px;
height: 32px;
}
}
.brand-text {
white-space: nowrap;
@media (max-width: $phone-max) {
display: none;
width: 40px;
height: 40px;
}
}
}
.nav-links {
display: flex;
flex-direction: column;
align-items: center;
gap: $unit;
flex: 1;
justify-content: right;
@media (max-width: $phone-max) {
gap: 0;
}
gap: $unit-half;
width: 100%;
}
.nav-link {
display: flex;
flex-direction: column;
align-items: center;
gap: $unit;
padding: $unit $unit-2x;
border-radius: $card-corner-radius;
justify-content: center;
gap: $unit-half;
padding: $unit-2x $unit-half;
border-radius: $corner-radius-2xl;
text-decoration: none;
font-size: 0.925rem;
font-size: 0.75rem;
font-weight: 500;
color: $gray-30;
transition: all 0.2s ease;
position: relative;
@media (max-width: $phone-max) {
padding: $unit-2x $unit;
}
width: 100%;
&:hover {
background-color: $gray-70;
@ -221,22 +137,22 @@
}
.nav-icon {
font-size: 1.1rem;
font-size: 1.5rem;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
@media (max-width: $tablet-max) {
font-size: 1rem;
:global(svg) {
width: 24px;
height: 24px;
}
}
.nav-text {
@media (max-width: $phone-max) {
display: none;
}
text-align: center;
white-space: nowrap;
line-height: 1.2;
}
}
.nav-actions {
// Placeholder for future actions if needed
}
</style>

View file

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

View file

@ -5,7 +5,8 @@
import AdminSegmentedControl from './AdminSegmentedControl.svelte'
import Input from './Input.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 SmartImage from '../SmartImage.svelte'
import Composer from './composer'
@ -46,6 +47,19 @@
{ 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
let formData = $state({
title: '',
@ -231,11 +245,6 @@
}
}
async function handleStatusChange(newStatus: string) {
formData.status = newStatus as any
await handleSave()
}
async function handleBulkAlbumSave() {
// Reload album to get updated photo count
if (album && mode === 'edit') {
@ -255,17 +264,7 @@
<AdminPage>
<header slot="header">
<div class="header-left">
<button class="btn-icon" onclick={() => goto('/admin/albums')} aria-label="Back to albums">
<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>
<h1 class="form-title">{formData.title || 'Untitled Album'}</h1>
</div>
<div class="header-center">
<AdminSegmentedControl
@ -276,18 +275,9 @@
</div>
<div class="header-actions">
{#if !isLoading}
<StatusDropdown
currentStatus={formData.status}
onStatusChange={handleStatusChange}
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}
<AutoSaveStatus
status="idle"
lastSavedAt={album?.updatedAt}
/>
{/if}
</div>
@ -338,6 +328,13 @@
disabled={isSaving}
/>
</div>
<DropdownSelectField
label="Status"
bind:value={formData.status}
options={statusOptions}
disabled={isSaving}
/>
</div>
<!-- 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 {
width: 40px;
height: 40px;

View file

@ -1,19 +1,31 @@
<script lang="ts">
import type { AutoSaveStatus } from '$lib/admin/autoSave'
import { formatTimeAgo } from '$lib/utils/time'
interface Props {
statusStore?: { subscribe: (run: (v: AutoSaveStatus) => void) => () => void }
errorStore?: { subscribe: (run: (v: string | null) => void) => () => void }
status?: AutoSaveStatus
error?: string | null
lastSavedAt?: Date | string | null
showTimestamp?: 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
let status = $state<AutoSaveStatus>('idle')
let errorText = $state<string | null>(null)
let refreshKey = $state(0) // Used to force re-render for time updates
$effect(() => {
// 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(() => {
// Force dependency on refreshKey to trigger re-computation
refreshKey
switch (status) {
case 'saving':
return 'Saving…'
case 'saved':
return 'All changes saved'
case 'idle':
return lastSavedAt && showTimestamp
? `Saved ${formatTimeAgo(lastSavedAt)}`
: 'All changes saved'
case 'offline':
return 'Offline'
case 'error':
return errorText ? `Error — ${errorText}` : 'Save failed'
case 'idle':
default:
return ''
}

View file

@ -3,9 +3,18 @@
onclick?: (event: MouseEvent) => void
variant?: 'default' | 'danger'
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) {
if (disabled) return
@ -18,10 +27,20 @@
class="dropdown-item"
class:danger={variant === 'danger'}
class:disabled
class:has-description={!!description}
{disabled}
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>
<style lang="scss">
@ -38,12 +57,20 @@
cursor: pointer;
transition: background-color 0.2s ease;
&.has-description {
padding: $unit-2x $unit-3x;
}
&:hover:not(:disabled) {
background-color: $gray-95;
}
&.danger {
color: $red-60;
.dropdown-item-label {
color: $red-60;
}
}
&:disabled {
@ -51,4 +78,23 @@
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>

View file

@ -5,11 +5,11 @@
import Editor from './Editor.svelte'
import Button from './Button.svelte'
import Input from './Input.svelte'
import DropdownSelectField from './DropdownSelectField.svelte'
import { toast } from '$lib/stores/toast'
import { makeDraftKey, saveDraft, loadDraft, clearDraft, timeAgo } from '$lib/admin/draftStore'
import { createAutoSaveStore } from '$lib/admin/autoSave.svelte'
import AutoSaveStatus from './AutoSaveStatus.svelte'
import { clickOutside } from '$lib/actions/clickOutside'
import type { JSONContent } from '@tiptap/core'
interface Props {
@ -32,7 +32,6 @@
let hasLoaded = $state(mode === 'create') // Create mode loads immediately
let isSaving = $state(false)
let activeTab = $state('metadata')
let showPublishMenu = $state(false)
let updatedAt = $state<string | undefined>(initialData?.updatedAt)
// Form data
@ -94,6 +93,19 @@ let autoSave = mode === 'edit' && postId
{ 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
$effect(() => {
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>
<AdminPage>
<header slot="header">
<div class="header-left">
<Button variant="ghost" iconOnly onclick={() => goto('/admin/posts')}>
<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>
<h1 class="form-title">{title || 'Untitled Essay'}</h1>
</div>
<div class="header-center">
<AdminSegmentedControl
@ -344,56 +327,12 @@ $effect(() => {
/>
</div>
<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}
<AutoSaveStatus status={autoSave.status} error={autoSave.lastError} />
<AutoSaveStatus
status={autoSave.status}
error={autoSave.lastError}
lastSavedAt={initialData?.updatedAt}
/>
{/if}
</div>
</header>
@ -442,6 +381,12 @@ $effect(() => {
<Input label="Slug" bind:value={slug} placeholder="essay-url-slug" />
<DropdownSelectField
label="Status"
bind:value={status}
options={statusOptions}
/>
<div class="tags-field">
<label class="input-label">Tags</label>
<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 {
width: 100%;
margin: 0 auto;

View file

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

View file

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

View file

@ -7,7 +7,6 @@
import ProjectMetadataForm from './ProjectMetadataForm.svelte'
import ProjectBrandingForm from './ProjectBrandingForm.svelte'
import ProjectImagesForm from './ProjectImagesForm.svelte'
import StatusDropdown from './StatusDropdown.svelte'
import AutoSaveStatus from './AutoSaveStatus.svelte'
import DraftPrompt from './DraftPrompt.svelte'
import { toast } from '$lib/stores/toast'
@ -71,6 +70,7 @@
const tabOptions = [
{ value: 'metadata', label: 'Metadata' },
{ value: 'branding', label: 'Branding' },
{ value: 'case-study', label: 'Case Study' }
]
@ -169,26 +169,13 @@
}
}
async function handleStatusChange(newStatus: string) {
formStore.setField('status', newStatus)
await handleSave()
}
</script>
<AdminPage>
<header slot="header">
<div class="header-left">
<button class="btn-icon" onclick={() => goto('/admin/projects')}>
<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>
<h1 class="form-title">{formStore.fields.title || 'Untitled Project'}</h1>
</div>
<div class="header-center">
<AdminSegmentedControl
@ -198,29 +185,12 @@
/>
</div>
<div class="header-actions">
{#if !isLoading}
<StatusDropdown
currentStatus={formStore.fields.status}
onStatusChange={handleStatusChange}
disabled={isSaving}
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 !isLoading && mode === 'edit' && autoSave}
<AutoSaveStatus
status={autoSave.status}
error={autoSave.lastError}
lastSavedAt={project?.updatedAt}
/>
{#if mode === 'edit' && autoSave}
<AutoSaveStatus status={autoSave.status} error={autoSave.lastError} />
{/if}
{/if}
</div>
</header>
@ -256,8 +226,20 @@
}}
>
<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} />
<ProjectImagesForm bind:formData={formStore.fields} validationErrors={formStore.validationErrors} onSave={handleSave} />
</form>
</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 {
width: 40px;
height: 40px;

View file

@ -3,6 +3,7 @@
import Textarea from './Textarea.svelte'
import SelectField from './SelectField.svelte'
import SegmentedControlField from './SegmentedControlField.svelte'
import DropdownSelectField from './DropdownSelectField.svelte'
import type { ProjectFormData } from '$lib/types/project'
interface Props {
@ -12,6 +13,29 @@
}
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>
<div class="form-section">
@ -34,14 +58,22 @@
/>
<Input
type="url"
size="jumbo"
label="External URL"
type="url"
error={validationErrors.externalUrl}
bind:value={formData.externalUrl}
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
label="Project Type"
bind:value={formData.projectType}
@ -51,10 +83,13 @@
{ value: 'labs', label: 'Labs' }
]}
/>
</div>
<div class="form-row two-column">
<Input
type="number"
label="Year"
size="jumbo"
required
error={validationErrors.year}
bind:value={formData.year}
@ -64,6 +99,7 @@
<Input
label="Client"
size="jumbo"
error={validationErrors.client}
bind:value={formData.client}
placeholder="Client or company name"

View file

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

View file

@ -47,12 +47,12 @@
{isLoading}
/>
{: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'}
</Button>
{:else}
<!-- 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'}
</Button>
{/if}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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