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%;
|
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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 ''
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue