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:
parent
6ca6727eda
commit
cf2842d22d
19 changed files with 306 additions and 320 deletions
|
|
@ -24,7 +24,7 @@
|
|||
width: 100%;
|
||||
|
||||
h1 {
|
||||
font-size: 1.75rem;
|
||||
font-size: $font-size-large;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: $gray-10;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 ''
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;"
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@
|
|||
<Button
|
||||
bind:this={buttonRef}
|
||||
variant="primary"
|
||||
buttonSize="large"
|
||||
buttonSize="medium"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation()
|
||||
isOpen = !isOpen
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@
|
|||
{#snippet trigger()}
|
||||
<Button
|
||||
variant="primary"
|
||||
buttonSize="large"
|
||||
buttonSize="medium"
|
||||
onclick={handlePublishClick}
|
||||
disabled={disabled || isLoading}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@
|
|||
{#snippet trigger()}
|
||||
<Button
|
||||
variant="primary"
|
||||
buttonSize="large"
|
||||
buttonSize="medium"
|
||||
onclick={handlePrimaryAction}
|
||||
disabled={disabled || isLoading}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -116,7 +116,7 @@
|
|||
{#snippet actions()}
|
||||
<Button
|
||||
variant="primary"
|
||||
buttonSize="large"
|
||||
buttonSize="medium"
|
||||
onclick={() => goto('/admin/projects/new')}
|
||||
>
|
||||
New project
|
||||
|
|
|
|||
Loading…
Reference in a new issue