Fixes for UniverseComposer and delete modals.

Also standardizing Publish buttons and whatnot
This commit is contained in:
Justin Edmund 2025-06-02 06:19:39 -07:00
parent e7a7e7cd1e
commit b2ad9efd9c
39 changed files with 933 additions and 464 deletions

View file

@ -20,6 +20,8 @@ $unit-8x: $unit * 8;
$unit-10x: $unit * 10;
$unit-12x: $unit * 12;
$unit-14x: $unit * 14;
$unit-16x: $unit * 16;
$unit-18x: $unit * 18;
$unit-20x: $unit * 20;
/* Page properties

View file

@ -188,22 +188,55 @@
</div>
<div class="project-content">
<p class="project-description">{@html highlightedDescription}</p>
{#if isListOnly}
<div class="status-indicator list-only">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="2"/>
<path d="M20.188 10.934c.388.472.612 1.057.612 1.686 0 .63-.224 1.214-.612 1.686a11.79 11.79 0 01-1.897 1.853c-1.481 1.163-3.346 2.24-5.291 2.24-1.945 0-3.81-1.077-5.291-2.24A11.79 11.79 0 016.812 14.32C6.224 13.648 6 13.264 6 12.62c0-.63.224-1.214.612-1.686A11.79 11.79 0 018.709 9.08c1.481-1.163 3.346-2.24 5.291-2.24 1.945 0 3.81 1.077 5.291 2.24a11.79 11.79 0 011.897 1.853z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M2 2l20 20" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="2" />
<path
d="M20.188 10.934c.388.472.612 1.057.612 1.686 0 .63-.224 1.214-.612 1.686a11.79 11.79 0 01-1.897 1.853c-1.481 1.163-3.346 2.24-5.291 2.24-1.945 0-3.81-1.077-5.291-2.24A11.79 11.79 0 016.812 14.32C6.224 13.648 6 13.264 6 12.62c0-.63.224-1.214.612-1.686A11.79 11.79 0 018.709 9.08c1.481-1.163 3.346-2.24 5.291-2.24 1.945 0 3.81 1.077 5.291 2.24a11.79 11.79 0 011.897 1.853z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path d="M2 2l20 20" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
<span>Coming Soon</span>
</div>
{:else if isPasswordProtected}
<div class="status-indicator password-protected">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" stroke="currentColor" stroke-width="2"/>
<circle cx="12" cy="16" r="1" fill="currentColor"/>
<path d="M7 11V7a5 5 0 0 1 10 0v4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect
x="3"
y="11"
width="18"
height="11"
rx="2"
ry="2"
stroke="currentColor"
stroke-width="2"
/>
<circle cx="12" cy="16" r="1" fill="currentColor" />
<path
d="M7 11V7a5 5 0 0 1 10 0v4"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
<span>Password Required</span>
</div>
@ -248,7 +281,7 @@
}
&.odd {
flex-direction: row-reverse;
// flex-direction: row-reverse;
}
}

View file

@ -16,8 +16,8 @@
const navItems: NavItem[] = [
{ icon: WorkIcon, text: 'Work', href: '/', variant: 'work' },
{ icon: PhotosIcon, text: 'Photos', href: '/photos', variant: 'photos' },
{ icon: LabsIcon, text: 'Labs', href: '/labs', variant: 'labs' },
{ icon: PhotosIcon, text: 'Photos', href: '/photos', variant: 'photos' },
{ icon: UniverseIcon, text: 'Universe', href: '/universe', variant: 'universe' }
]
@ -28,9 +28,9 @@
const activeIndex = $derived(
currentPath === '/'
? 0
: currentPath.startsWith('/photos')
: currentPath.startsWith('/labs')
? 1
: currentPath.startsWith('/labs')
: currentPath.startsWith('/photos')
? 2
: currentPath.startsWith('/universe')
? 3

View file

@ -31,6 +31,7 @@
margin: 0 auto $unit-6x;
width: calc(100% - #{$unit-6x});
max-width: 900px; // Much wider for admin
min-height: calc(100vh - #{$unit-16x}); // Full height minus margins
overflow: hidden; // Ensure border-radius clips content
&:first-child {

View file

@ -3,7 +3,7 @@
interface Props extends HTMLButtonAttributes {
variant?: 'primary' | 'secondary' | 'danger' | 'ghost' | 'text' | 'overlay'
size?: 'small' | 'medium' | 'large' | 'icon'
buttonSize?: 'small' | 'medium' | 'large' | 'icon'
iconOnly?: boolean
iconPosition?: 'left' | 'right'
pill?: boolean
@ -16,7 +16,7 @@
let {
variant = 'primary',
size = 'medium',
buttonSize = 'medium',
iconOnly = false,
iconPosition = 'left',
pill = true,
@ -41,10 +41,10 @@
// Size
if (!iconOnly) {
classes.push(`btn-${size}`)
classes.push(`btn-${buttonSize}`)
} else {
classes.push('btn-icon')
classes.push(`btn-icon-${size}`)
classes.push(`btn-icon-${buttonSize}`)
}
// States
@ -254,8 +254,8 @@
}
&.btn-icon-large {
width: 40px;
height: 40px;
width: 44px;
height: 44px;
border-radius: 10px;
}

View file

@ -64,7 +64,7 @@
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
z-index: 1050;
}
.modal {

View file

@ -0,0 +1,59 @@
<script lang="ts">
interface Props {
onclick?: (event: MouseEvent) => void
variant?: 'default' | 'danger'
disabled?: boolean
}
let {
onclick,
variant = 'default',
disabled = false,
children
}: Props = $props()
function handleClick(event: MouseEvent) {
if (disabled) return
event.stopPropagation()
onclick?.(event)
}
</script>
<button
class="dropdown-item"
class:danger={variant === 'danger'}
class:disabled
{disabled}
onclick={handleClick}
>
{@render children()}
</button>
<style lang="scss">
@import '$styles/variables.scss';
.dropdown-item {
width: 100%;
padding: $unit-2x $unit-3x;
background: none;
border: none;
text-align: left;
font-size: 0.875rem;
color: $grey-20;
cursor: pointer;
transition: background-color 0.2s ease;
&:hover:not(:disabled) {
background-color: $grey-95;
}
&.danger {
color: $red-60;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
</style>

View file

@ -0,0 +1,134 @@
<script lang="ts">
import { createEventDispatcher, onMount } from 'svelte'
import { browser } from '$app/environment'
interface Props {
isOpen: boolean
triggerElement?: HTMLElement
items: DropdownItem[]
onClose?: () => void
}
interface DropdownItem {
id: string
label: string
action: () => void
variant?: 'default' | 'danger'
divider?: boolean
}
let {
isOpen = $bindable(),
triggerElement,
items,
onClose
}: Props = $props()
let dropdownElement: HTMLDivElement
const dispatch = createEventDispatcher()
// Calculate position dynamically when needed
const position = $derived(() => {
if (!isOpen || !triggerElement || !browser) {
return { top: 0, left: 0 }
}
const rect = triggerElement.getBoundingClientRect()
const dropdownWidth = 180
return {
top: rect.bottom + 4,
left: rect.right - dropdownWidth
}
})
function handleItemClick(item: DropdownItem, event: MouseEvent) {
event.stopPropagation()
item.action()
isOpen = false
onClose?.()
}
function handleOutsideClick(event: MouseEvent) {
if (!dropdownElement || !isOpen) return
const target = event.target as HTMLElement
if (!dropdownElement.contains(target) && !triggerElement?.contains(target)) {
isOpen = false
onClose?.()
}
}
$effect(() => {
if (browser && isOpen) {
document.addEventListener('click', handleOutsideClick)
return () => {
document.removeEventListener('click', handleOutsideClick)
}
}
})
</script>
{#if isOpen && browser}
<div
bind:this={dropdownElement}
class="dropdown-menu"
style="top: {position().top}px; left: {position().left}px"
>
{#each items as item}
{#if item.divider}
<div class="dropdown-divider"></div>
{:else}
<button
class="dropdown-item"
class:danger={item.variant === 'danger'}
onclick={(e) => handleItemClick(item, e)}
>
{item.label}
</button>
{/if}
{/each}
</div>
{/if}
<style lang="scss">
@import '$styles/variables.scss';
.dropdown-menu {
position: fixed;
background: white;
border: 1px solid $grey-85;
border-radius: $unit;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
overflow: hidden;
min-width: 180px;
z-index: 1000;
}
.dropdown-item {
width: 100%;
padding: $unit-2x $unit-3x;
background: none;
border: none;
text-align: left;
font-size: 0.875rem;
color: $grey-20;
cursor: pointer;
transition: background-color 0.2s ease;
display: block;
&:hover {
background-color: $grey-95;
}
&.danger {
color: $red-60;
}
}
.dropdown-divider {
height: 1px;
background-color: $grey-90;
margin: $unit-half 0;
}
</style>

View file

@ -0,0 +1,28 @@
<script lang="ts">
interface Props {
class?: string
}
let { class: className = '', children }: Props = $props()
</script>
<div class="dropdown-menu {className}">
{@render children()}
</div>
<style lang="scss">
@import '$styles/variables.scss';
.dropdown-menu {
position: absolute;
top: calc(100% + $unit-half);
right: 0;
background: white;
border: 1px solid $grey-85;
border-radius: $unit;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
overflow: hidden;
min-width: 180px;
z-index: 10;
}
</style>

View file

@ -206,28 +206,54 @@
}
})
// Custom image paste handler
function handleImagePaste(view: any, event: ClipboardEvent) {
const item = event.clipboardData?.items[0]
// Custom paste handler for both images and text
function handlePaste(view: any, event: ClipboardEvent) {
const clipboardData = event.clipboardData
if (!clipboardData) return false
if (item?.type.indexOf('image') !== 0) {
return false
// Check for images first
const imageItem = Array.from(clipboardData.items).find(item => item.type.indexOf('image') === 0)
if (imageItem) {
const file = imageItem.getAsFile()
if (!file) return false
// Check file size (2MB max)
const filesize = file.size / 1024 / 1024
if (filesize > 2) {
alert(`Image too large! File size: ${filesize.toFixed(2)} MB (max 2MB)`)
return true
}
// Upload to our media API
uploadImage(file)
return true // Prevent default paste behavior
}
const file = item.getAsFile()
if (!file) return false
// Check file size (2MB max)
const filesize = file.size / 1024 / 1024
if (filesize > 2) {
alert(`Image too large! File size: ${filesize.toFixed(2)} MB (max 2MB)`)
return true
// Handle text paste - strip HTML formatting
const htmlData = clipboardData.getData('text/html')
const plainText = clipboardData.getData('text/plain')
if (htmlData && plainText) {
// If we have both HTML and plain text, use plain text to strip formatting
event.preventDefault()
// Use editor commands to insert text so all callbacks are triggered
const editorInstance = (view as any).editor
if (editorInstance) {
editorInstance.chain().focus().insertContent(plainText).run()
} else {
// Fallback to manual transaction
const { state, dispatch } = view
const { selection } = state
const transaction = state.tr.insertText(plainText, selection.from, selection.to)
dispatch(transaction)
}
return true // Prevent default paste behavior
}
// Upload to our media API
uploadImage(file)
return true // Prevent default paste behavior
// Let default handling take care of plain text only
return false
}
async function uploadImage(file: File) {
@ -321,9 +347,10 @@
attributes: {
class: 'prose prose-sm max-w-none focus:outline-none'
},
handlePaste: handleImagePaste
handlePaste: handlePaste
}
}
},
placeholder
)
// Add placeholder

View file

@ -204,7 +204,7 @@
<Button
variant="primary"
iconOnly
size="medium"
buttonSize="medium"
active={showPublishMenu}
onclick={togglePublishMenu}
disabled={isSaving}
@ -295,7 +295,7 @@
placeholder="Add tags..."
wrapperClass="tag-input"
/>
<Button variant="secondary" size="small" type="button" onclick={addTag}>
<Button variant="secondary" buttonSize="small" type="button" onclick={addTag}>
Add
</Button>
</div>
@ -307,7 +307,7 @@
<Button
variant="ghost"
iconOnly
size="small"
buttonSize="small"
onclick={() => removeTag(tag)}
aria-label="Remove {tag}"
>

View file

@ -475,7 +475,7 @@
label="Alt Text"
value={media.altText || ''}
placeholder="Describe this image"
size="small"
buttonSize="small"
onblur={(e) => handleAltTextChange(media, e.target.value)}
/>
</div>

View file

@ -283,11 +283,11 @@
<!-- Overlay with actions -->
<div class="preview-overlay">
<div class="preview-actions">
<Button variant="overlay" size="small" onclick={handleBrowseClick}>
<Button variant="overlay" buttonSize="small" onclick={handleBrowseClick}>
<RefreshIcon slot="icon" width="12" height="12" />
</Button>
<Button variant="overlay" size="small" onclick={handleRemove}>
<Button variant="overlay" buttonSize="small" onclick={handleRemove}>
<svg slot="icon" width="12" height="12" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<polyline points="3,6 5,6 21,6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M19 6V20A2 2 0 0 1 17 22H7A2 2 0 0 1 5 20V6M8 6V4A2 2 0 0 1 10 2H14A2 2 0 0 1 16 4V6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
@ -306,7 +306,7 @@
label="Alt Text"
bind:value={altTextValue}
placeholder="Describe this image for screen readers"
size="small"
buttonSize="small"
onblur={handleAltTextChange}
/>
@ -316,7 +316,7 @@
bind:value={descriptionValue}
placeholder="Additional description or caption"
rows={2}
size="small"
buttonSize="small"
onblur={handleDescriptionChange}
/>
</div>
@ -338,12 +338,12 @@
<!-- Overlay with actions -->
<div class="preview-overlay">
<div class="preview-actions">
<Button variant="overlay" size="small" onclick={handleBrowseClick}>
<Button variant="overlay" buttonSize="small" onclick={handleBrowseClick}>
<RefreshIcon slot="icon" width="16" height="16" />
Replace
</Button>
<Button variant="overlay" size="small" onclick={handleRemove}>
<Button variant="overlay" buttonSize="small" onclick={handleRemove}>
<svg slot="icon" width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<polyline points="3,6 5,6 21,6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M19 6V20A2 2 0 0 1 17 22H7A2 2 0 0 1 5 20V6M8 6V4A2 2 0 0 1 10 2H14A2 2 0 0 1 16 4V6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>

View file

@ -246,7 +246,7 @@
<span class="label">URL:</span>
<div class="url-section">
<span class="url-text">{media.url}</span>
<Button variant="ghost" size="small" onclick={copyUrl}>
<Button variant="ghost" buttonSize="small" onclick={copyUrl}>
Copy
</Button>
</div>

View file

@ -235,9 +235,9 @@
>
<!-- Thumbnail -->
<div class="media-thumbnail">
{#if item.thumbnailUrl}
{#if item.mimeType?.startsWith('image/')}
<img
src={item.thumbnailUrl}
src={item.mimeType === 'image/svg+xml' ? item.url : (item.thumbnailUrl || item.url)}
alt={item.filename}
loading="lazy"
/>
@ -317,7 +317,7 @@
class="load-more-button"
>
{#if loading}
<LoadingSpinner size="small" />
<LoadingSpinner buttonSize="small" />
Loading...
{:else}
Load More

View file

@ -258,17 +258,17 @@
<div class="file-list-header">
<h3>Files to Upload</h3>
<div class="file-actions">
<Button variant="secondary" size="small" onclick={clearAll} disabled={isUploading}>
<Button variant="secondary" buttonSize="small" onclick={clearAll} disabled={isUploading}>
Clear All
</Button>
<Button
variant="primary"
size="small"
buttonSize="small"
onclick={uploadFiles}
disabled={isUploading || files.length === 0}
>
{#if isUploading}
<LoadingSpinner size="small" />
<LoadingSpinner buttonSize="small" />
Uploading...
{:else}
Upload {files.length} File{files.length !== 1 ? 's' : ''}

View file

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

View file

@ -10,6 +10,7 @@
import ProjectGalleryForm from './ProjectGalleryForm.svelte'
import ProjectStylingForm from './ProjectStylingForm.svelte'
import Button from './Button.svelte'
import StatusDropdown from './StatusDropdown.svelte'
import { projectSchema } from '$lib/schemas/project'
import type { Project, ProjectFormData } from '$lib/types/project'
import { defaultProjectFormData } from '$lib/types/project'
@ -28,7 +29,6 @@
let successMessage = $state('')
let activeTab = $state('metadata')
let validationErrors = $state<Record<string, string>>({})
let showPublishMenu = $state(false)
// Form data
let formData = $state<ProjectFormData>({ ...defaultProjectFormData })
@ -195,29 +195,7 @@
async function handleStatusChange(newStatus: string) {
formData.status = newStatus as any
await handleSave()
showPublishMenu = false
}
function togglePublishMenu() {
showPublishMenu = !showPublishMenu
}
// Close menu when clicking outside
function handleClickOutside(event: MouseEvent) {
const target = event.target as HTMLElement
if (!target.closest('.save-actions')) {
showPublishMenu = false
}
}
$effect(() => {
if (showPublishMenu) {
document.addEventListener('click', handleClickOutside)
return () => {
document.removeEventListener('click', handleClickOutside)
}
}
})
</script>
<AdminPage>
@ -244,65 +222,24 @@
</div>
<div class="header-actions">
{#if !isLoading}
<div class="save-actions">
<Button variant="primary" onclick={handleSave} disabled={isSaving} class="save-button">
{isSaving ? 'Saving...' :
formData.status === 'published' ? 'Save' :
formData.status === 'list-only' ? 'Save List-Only' :
formData.status === 'password-protected' ? 'Save Protected' :
'Save Draft'}
{#if formData.status === 'published'}
<Button variant="primary" buttonSize="large" onclick={handleSave} disabled={isSaving}>
{isSaving ? 'Saving...' : 'Save'}
</Button>
<Button
variant="ghost"
iconOnly
size="medium"
active={showPublishMenu}
onclick={togglePublishMenu}
{:else}
<StatusDropdown
currentStatus={formData.status}
onStatusChange={handleStatusChange}
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 formData.status !== 'draft'}
<Button variant="ghost" onclick={() => handleStatusChange('draft')} class="menu-item" fullWidth>
Save as Draft
</Button>
{/if}
{#if formData.status !== 'published'}
<Button variant="ghost" onclick={() => handleStatusChange('published')} class="menu-item" fullWidth>
Publish
</Button>
{/if}
{#if formData.status !== 'list-only'}
<Button variant="ghost" onclick={() => handleStatusChange('list-only')} class="menu-item" fullWidth>
List Only
</Button>
{/if}
{#if formData.status !== 'password-protected'}
<Button variant="ghost" onclick={() => handleStatusChange('password-protected')} class="menu-item" fullWidth>
Password Protected
</Button>
{/if}
</div>
{/if}
</div>
isLoading={isSaving}
primaryAction={{ label: 'Publish', status: 'published' }}
dropdownActions={[
{ label: 'Save as Draft', status: 'draft' },
{ label: 'List Only', status: 'list-only', show: formData.status !== 'list-only' },
{ label: 'Password Protected', status: 'password-protected', show: formData.status !== 'password-protected' }
]}
/>
{/if}
{/if}
</div>
</header>
@ -414,54 +351,7 @@
}
}
.save-actions {
position: relative;
display: flex;
gap: $unit-half;
}
/* Button-specific styles handled by Button component */
/* Custom button styles */
:global(.save-button) {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
padding-right: $unit-2x;
}
:global(.chevron-button) {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
border-left: 1px solid rgba(255, 255, 255, 0.2);
svg {
display: block;
transition: transform 0.2s ease;
}
&.active svg {
transform: rotate(180deg);
}
}
.publish-menu {
position: absolute;
top: 100%;
right: 0;
margin-top: $unit;
background: white;
border-radius: $unit;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
overflow: hidden;
min-width: 120px;
z-index: 100;
/* Menu item styles handled by Button component */
:global(.menu-item) {
text-align: left;
justify-content: flex-start;
}
}
.tab-panels {
position: relative;

View file

@ -1,6 +1,6 @@
<script lang="ts">
import { goto } from '$app/navigation'
import { createEventDispatcher } from 'svelte'
import { createEventDispatcher, onMount } from 'svelte'
import AdminByline from './AdminByline.svelte'
interface Project {
@ -21,18 +21,18 @@
interface Props {
project: Project
isDropdownActive?: boolean
}
let { project, isDropdownActive = false }: Props = $props()
let { project }: Props = $props()
const dispatch = createEventDispatcher<{
toggleDropdown: { projectId: number; event: MouseEvent }
edit: { project: Project; event: MouseEvent }
togglePublish: { project: Project; event: MouseEvent }
delete: { project: Project; event: MouseEvent }
edit: { project: Project }
togglePublish: { project: Project }
delete: { project: Project }
}>()
let isDropdownOpen = $state(false)
function formatRelativeTime(dateString: string): string {
const date = new Date(dateString)
const now = new Date()
@ -61,20 +61,31 @@
}
function handleToggleDropdown(event: MouseEvent) {
dispatch('toggleDropdown', { projectId: project.id, event })
event.stopPropagation()
isDropdownOpen = !isDropdownOpen
}
function handleEdit(event: MouseEvent) {
dispatch('edit', { project, event })
function handleEdit() {
dispatch('edit', { project })
}
function handleTogglePublish(event: MouseEvent) {
dispatch('togglePublish', { project, event })
function handleTogglePublish() {
dispatch('togglePublish', { project })
}
function handleDelete(event: MouseEvent) {
dispatch('delete', { project, event })
function handleDelete() {
dispatch('delete', { project })
}
onMount(() => {
function handleCloseDropdowns() {
isDropdownOpen = false
}
document.addEventListener('closeDropdowns', handleCloseDropdowns)
return () => document.removeEventListener('closeDropdowns', handleCloseDropdowns)
})
</script>
<div
@ -104,7 +115,11 @@
</div>
<div class="dropdown-container">
<button class="action-button" onclick={handleToggleDropdown} aria-label="Project actions">
<button
class="action-button"
onclick={handleToggleDropdown}
aria-label="Project actions"
>
<svg
width="20"
height="20"
@ -118,14 +133,14 @@
</svg>
</button>
{#if isDropdownActive}
{#if isDropdownOpen}
<div class="dropdown-menu">
<button class="dropdown-item" onclick={handleEdit}> Edit project </button>
<button class="dropdown-item" onclick={handleEdit}>Edit project</button>
<button class="dropdown-item" onclick={handleTogglePublish}>
{project.status === 'published' ? 'Unpublish' : 'Publish'} project
</button>
<div class="dropdown-divider"></div>
<button class="dropdown-item delete" onclick={handleDelete}> Delete project </button>
<button class="dropdown-item danger" onclick={handleDelete}>Delete project</button>
</div>
{/if}
</div>
@ -240,7 +255,7 @@
background-color: $grey-95;
}
&.delete {
&.danger {
color: $red-60;
}
}
@ -250,4 +265,5 @@
background-color: $grey-90;
margin: $unit-half 0;
}
</style>

View file

@ -0,0 +1,107 @@
<script lang="ts">
import Button from './Button.svelte'
import DropdownMenuContainer from './DropdownMenuContainer.svelte'
import DropdownItem from './DropdownItem.svelte'
interface Props {
onPublish: () => void
onSaveDraft: () => void
disabled?: boolean
isLoading?: boolean
publishText?: string
saveDraftText?: string
loadingText?: string
showDropdown?: boolean
}
let {
onPublish,
onSaveDraft,
disabled = false,
isLoading = false,
publishText = 'Publish',
saveDraftText = 'Save as Draft',
loadingText = 'Publishing...',
showDropdown = true
}: Props = $props()
let isDropdownOpen = $state(false)
function handlePublishClick() {
onPublish()
isDropdownOpen = false
}
function handleSaveDraftClick() {
onSaveDraft()
isDropdownOpen = false
}
function handleDropdownToggle(e: MouseEvent) {
e.stopPropagation()
isDropdownOpen = !isDropdownOpen
}
function handleClickOutside(event: MouseEvent) {
const target = event.target as HTMLElement
if (!target.closest('.publish-dropdown')) {
isDropdownOpen = false
}
}
$effect(() => {
if (isDropdownOpen) {
document.addEventListener('click', handleClickOutside)
return () => document.removeEventListener('click', handleClickOutside)
}
})
</script>
<div class="publish-dropdown">
<Button
variant="primary"
buttonSize="large"
onclick={handlePublishClick}
disabled={disabled || isLoading}
>
{isLoading ? loadingText : publishText}
</Button>
{#if showDropdown}
<Button
variant="ghost"
iconOnly
buttonSize="large"
onclick={handleDropdownToggle}
disabled={disabled || isLoading}
>
<svg slot="icon" width="12" height="12" viewBox="0 0 12 12" fill="none">
<path
d="M3 4.5L6 7.5L9 4.5"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</Button>
{#if isDropdownOpen}
<DropdownMenuContainer>
<DropdownItem onclick={handleSaveDraftClick}>
{saveDraftText}
</DropdownItem>
</DropdownMenuContainer>
{/if}
{/if}
</div>
<style lang="scss">
@import '$styles/variables.scss';
.publish-dropdown {
position: relative;
display: flex;
gap: $unit-half;
}
</style>

View file

@ -0,0 +1,68 @@
<script lang="ts">
import Button from './Button.svelte'
import PublishDropdown from './PublishDropdown.svelte'
interface Props {
status: 'draft' | 'published' | string
onSave: (status?: string) => void
disabled?: boolean
isLoading?: boolean
canSave?: boolean
customActions?: Array<{
label: string
status: string
variant?: 'default' | 'danger'
}>
}
let {
status,
onSave,
disabled = false,
isLoading = false,
canSave = true,
customActions = []
}: Props = $props()
function handlePublish() {
onSave('published')
}
function handleSaveDraft() {
onSave('draft')
}
function handleSave() {
onSave()
}
const isDisabled = $derived(disabled || isLoading || !canSave)
</script>
{#if status === 'draft'}
<PublishDropdown
onPublish={handlePublish}
onSaveDraft={handleSaveDraft}
disabled={isDisabled}
isLoading={isLoading}
/>
{:else if status === 'published'}
<Button
variant="primary"
buttonSize="large"
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}
>
{isLoading ? 'Saving...' : 'Save'}
</Button>
{/if}

View file

@ -0,0 +1,118 @@
<script lang="ts">
import Button from './Button.svelte'
import DropdownMenuContainer from './DropdownMenuContainer.svelte'
import DropdownItem from './DropdownItem.svelte'
interface Props {
currentStatus: string
onStatusChange: (status: string) => void
disabled?: boolean
isLoading?: boolean
primaryAction: {
label: string
status: string
}
dropdownActions?: Array<{
label: string
status: string
show?: boolean
}>
}
let {
currentStatus,
onStatusChange,
disabled = false,
isLoading = false,
primaryAction,
dropdownActions = []
}: Props = $props()
let isDropdownOpen = $state(false)
function handlePrimaryAction() {
onStatusChange(primaryAction.status)
isDropdownOpen = false
}
function handleDropdownAction(status: string) {
onStatusChange(status)
isDropdownOpen = false
}
function handleDropdownToggle(e: MouseEvent) {
e.stopPropagation()
isDropdownOpen = !isDropdownOpen
}
function handleClickOutside(event: MouseEvent) {
const target = event.target as HTMLElement
if (!target.closest('.status-dropdown')) {
isDropdownOpen = false
}
}
$effect(() => {
if (isDropdownOpen) {
document.addEventListener('click', handleClickOutside)
return () => document.removeEventListener('click', handleClickOutside)
}
})
const availableActions = $derived(
dropdownActions.filter(action =>
action.show !== false && action.status !== currentStatus
)
)
</script>
<div class="status-dropdown">
<Button
variant="primary"
buttonSize="large"
onclick={handlePrimaryAction}
disabled={disabled || isLoading}
>
{isLoading ? `${primaryAction.label.replace(/e$/, 'ing')}...` : primaryAction.label}
</Button>
{#if availableActions.length > 0}
<Button
variant="ghost"
iconOnly
buttonSize="large"
onclick={handleDropdownToggle}
disabled={disabled || isLoading}
>
<svg slot="icon" width="12" height="12" viewBox="0 0 12 12" fill="none">
<path
d="M3 4.5L6 7.5L9 4.5"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</Button>
{#if isDropdownOpen}
<DropdownMenuContainer>
{#each availableActions as action}
<DropdownItem onclick={() => handleDropdownAction(action.status)}>
{action.label}
</DropdownItem>
{/each}
</DropdownMenuContainer>
{/if}
{/if}
</div>
<style lang="scss">
@import '$styles/variables.scss';
.status-dropdown {
position: relative;
display: flex;
gap: $unit-half;
}
</style>

View file

@ -17,6 +17,7 @@
export let initialMode: 'modal' | 'page' = 'modal'
export let initialPostType: 'post' | 'essay' = 'post'
export let initialContent: JSONContent | undefined = undefined
export let closeOnSave = true
type PostType = 'post' | 'essay'
type ComposerMode = 'modal' | 'page'
@ -67,8 +68,7 @@
}
function resetComposer() {
postType = 'post'
mode = 'modal'
postType = initialPostType
content = {
type: 'doc',
content: [{ type: 'paragraph' }]
@ -248,7 +248,9 @@
if (response.ok) {
resetComposer()
isOpen = false
if (closeOnSave) {
isOpen = false
}
dispatch('saved')
if (postType === 'essay') {
goto('/admin/posts')
@ -372,7 +374,7 @@
<Button
variant="ghost"
iconOnly
size="icon"
buttonSize="icon"
onclick={toggleLinkFields}
active={showLinkFields}
title="Add link"
@ -403,7 +405,7 @@
<Button
variant="ghost"
iconOnly
size="icon"
buttonSize="icon"
onclick={handlePhotoUpload}
title="Add image"
class="tool-button"
@ -432,7 +434,7 @@
<Button
variant="ghost"
iconOnly
size="icon"
buttonSize="icon"
onclick={() => (isMediaLibraryOpen = true)}
title="Browse library"
class="tool-button"
@ -525,7 +527,7 @@
<Button
variant="ghost"
iconOnly
size="icon"
buttonSize="icon"
onclick={switchToEssay}
title="Switch to essay mode"
class="floating-expand-button"
@ -617,7 +619,7 @@
<Button
variant="ghost"
iconOnly
size="icon"
buttonSize="icon"
onclick={toggleLinkFields}
active={showLinkFields}
title="Add link"
@ -648,7 +650,7 @@
<Button
variant="ghost"
iconOnly
size="icon"
buttonSize="icon"
onclick={handlePhotoUpload}
title="Add image"
class="tool-button"
@ -677,7 +679,7 @@
<Button
variant="ghost"
iconOnly
size="icon"
buttonSize="icon"
onclick={() => (isMediaLibraryOpen = true)}
title="Browse library"
class="tool-button"

View file

@ -29,7 +29,8 @@ export const initiateEditor = (
content?: Content,
limit?: number,
extensions?: Extensions,
options?: Partial<EditorOptions>
options?: Partial<EditorOptions>,
placeholder?: string
): Editor => {
const editor = new Editor({
element: element,
@ -107,7 +108,7 @@ export const initiateEditor = (
if (node.type.name === 'heading') {
return 'Whats the title?'
} else if (node.type.name === 'paragraph') {
return 'Press / or write something ...'
return placeholder || 'Press / or write something ...'
}
return ''
}

View file

@ -53,8 +53,9 @@
showLinkBubbleMenu = true,
showTableBubbleMenu = true,
onUpdate,
children
}: EdraProps = $props()
children,
placeholder = undefined
}: EdraProps & { placeholder?: string } = $props()
let element = $state<HTMLElement>()
@ -90,7 +91,8 @@
editor = undefined
editor = props.editor
}
}
},
placeholder
)
return () => editor?.destroy()
})

View file

@ -206,7 +206,7 @@
<AdminPage>
<AdminHeader title="Albums" slot="header">
{#snippet actions()}
<Button variant="primary" size="large" onclick={handleNewAlbum}>New Album</Button>
<Button variant="primary" buttonSize="large" onclick={handleNewAlbum}>New Album</Button>
{/snippet}
</AdminHeader>
@ -219,7 +219,7 @@
<Select
bind:value={photographyFilter}
options={filterOptions}
size="small"
buttonSize="small"
variant="minimal"
onchange={handleFilterChange}
/>

View file

@ -11,6 +11,7 @@
import MediaLibraryModal from '$lib/components/admin/MediaLibraryModal.svelte'
import MediaDetailsModal from '$lib/components/admin/MediaDetailsModal.svelte'
import GalleryUploader from '$lib/components/admin/GalleryUploader.svelte'
import SaveActionsGroup from '$lib/components/admin/SaveActionsGroup.svelte'
// Form state
let album = $state<any>(null)
@ -28,7 +29,7 @@
let isSaving = $state(false)
let error = $state('')
let isDeleteModalOpen = $state(false)
// Photo management state
let isMediaLibraryOpen = $state(false)
let albumPhotos = $state<any[]>([])
@ -37,7 +38,7 @@
let uploadProgress = $state<Record<string, number>>({})
let uploadErrors = $state<string[]>([])
let fileInput: HTMLInputElement
// Media details modal state
let isMediaDetailsOpen = $state(false)
let selectedMedia = $state<any>(null)
@ -72,7 +73,7 @@
}
album = await response.json()
// Populate form fields
title = album.title || ''
slug = album.slug || ''
@ -82,10 +83,9 @@
isPhotography = album.isPhotography || false
showInUniverse = album.showInUniverse || false
status = album.status || 'draft'
// Populate photos
albumPhotos = album.photos || []
} catch (err) {
error = 'Failed to load album'
console.error('Failed to load album:', err)
@ -129,7 +129,7 @@
const response = await fetch(`/api/albums/${album.id}`, {
method: 'PUT',
headers: {
'Authorization': `Basic ${auth}`,
Authorization: `Basic ${auth}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(albumData)
@ -142,11 +142,10 @@
const updatedAlbum = await response.json()
album = updatedAlbum
if (publishStatus) {
status = publishStatus
}
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to update album'
console.error('Failed to update album:', err)
@ -174,7 +173,6 @@
}
goto('/admin/albums')
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to delete album'
console.error('Failed to delete album:', err)
@ -188,7 +186,7 @@
// Photo management functions
async function handleAddPhotos(selectedMedia: any | any[]) {
const mediaArray = Array.isArray(selectedMedia) ? selectedMedia : [selectedMedia]
try {
isManagingPhotos = true
const auth = localStorage.getItem('admin_auth')
@ -202,7 +200,7 @@
const response = await fetch(`/api/albums/${album.id}/photos`, {
method: 'POST',
headers: {
'Authorization': `Basic ${auth}`,
Authorization: `Basic ${auth}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
@ -218,12 +216,11 @@
const photo = await response.json()
albumPhotos = [...albumPhotos, photo]
}
// Update album photo count
if (album._count) {
album._count.photos = albumPhotos.length
}
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to add photos'
console.error('Failed to add photos:', err)
@ -248,7 +245,7 @@
const response = await fetch(`/api/photos/${photoId}`, {
method: 'DELETE',
headers: { 'Authorization': `Basic ${auth}` }
headers: { Authorization: `Basic ${auth}` }
})
if (!response.ok) {
@ -256,13 +253,12 @@
}
// Remove from local state
albumPhotos = albumPhotos.filter(photo => photo.id !== photoId)
albumPhotos = albumPhotos.filter((photo) => photo.id !== photoId)
// Update album photo count
if (album._count) {
album._count.photos = albumPhotos.length
}
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to remove photo'
console.error('Failed to remove photo:', err)
@ -303,8 +299,8 @@
function handleMediaUpdate(updatedMedia: any) {
// Update the photo in the album photos list
const photoIndex = albumPhotos.findIndex(photo =>
(photo.mediaId || photo.id) === updatedMedia.id
const photoIndex = albumPhotos.findIndex(
(photo) => (photo.mediaId || photo.id) === updatedMedia.id
)
if (photoIndex !== -1) {
// Update the photo with new media information
@ -317,7 +313,7 @@
}
albumPhotos = [...albumPhotos] // Trigger reactivity
}
// Update selectedMedia for the modal
selectedMedia = updatedMedia
}
@ -331,11 +327,11 @@
}
// Update display order for each photo
const updatePromises = reorderedPhotos.map((photo, index) =>
const updatePromises = reorderedPhotos.map((photo, index) =>
fetch(`/api/albums/${album.id}/photos`, {
method: 'PUT',
headers: {
'Authorization': `Basic ${auth}`,
Authorization: `Basic ${auth}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
@ -346,10 +342,9 @@
)
await Promise.all(updatePromises)
// Update local state
albumPhotos = reorderedPhotos
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to reorder photos'
console.error('Failed to reorder photos:', err)
@ -370,7 +365,7 @@
const response = await fetch(`/api/albums/${album.id}/photos`, {
method: 'POST',
headers: {
'Authorization': `Basic ${auth}`,
Authorization: `Basic ${auth}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
@ -386,12 +381,11 @@
const photo = await response.json()
albumPhotos = [...albumPhotos, photo]
}
// Update album photo count
if (album._count) {
album._count.photos = albumPhotos.length
}
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to add photos to album'
console.error('Failed to add photos to album:', err)
@ -425,10 +419,13 @@
}
// Filter for image files
const imageFiles = files.filter(file => file.type.startsWith('image/'))
const imageFiles = files.filter((file) => file.type.startsWith('image/'))
if (imageFiles.length !== files.length) {
uploadErrors = [...uploadErrors, `${files.length - imageFiles.length} non-image files were skipped`]
uploadErrors = [
...uploadErrors,
`${files.length - imageFiles.length} non-image files were skipped`
]
}
try {
@ -438,7 +435,7 @@
// First upload the file to media library
const formData = new FormData()
formData.append('file', file)
// If this is a photography album, mark the uploaded media as photography
if (isPhotography) {
formData.append('isPhotography', 'true')
@ -447,7 +444,7 @@
const uploadResponse = await fetch('/api/media/upload', {
method: 'POST',
headers: {
'Authorization': `Basic ${auth}`
Authorization: `Basic ${auth}`
},
body: formData
})
@ -465,7 +462,7 @@
const addResponse = await fetch(`/api/albums/${album.id}/photos`, {
method: 'POST',
headers: {
'Authorization': `Basic ${auth}`,
Authorization: `Basic ${auth}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
@ -482,7 +479,6 @@
const photo = await addResponse.json()
albumPhotos = [...albumPhotos, photo]
uploadProgress = { ...uploadProgress, [file.name]: 100 }
} catch (err) {
uploadErrors = [...uploadErrors, `${file.name}: Network error`]
}
@ -492,7 +488,6 @@
if (album._count) {
album._count.photos = albumPhotos.length
}
} finally {
isUploading = false
// Clear progress after a delay
@ -517,6 +512,7 @@
}
})
const canSave = $derived(title.trim().length > 0 && slug.trim().length > 0)
</script>
@ -535,10 +531,14 @@
/>
</svg>
</button>
<h1>🖼️ Edit Album</h1>
</div>
<div class="header-actions">
<Button variant="ghost" onclick={() => isDeleteModalOpen = true} disabled={isSaving}>
<Button
variant="ghost"
buttonSize="large"
onclick={() => (isDeleteModalOpen = true)}
disabled={isSaving}
>
<svg slot="icon" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path
d="M6 3V2C6 1.44772 6.44772 1 7 1H9C9.55228 1 10 1.44772 10 2V3M13 4H3M5 7V12M8 7V12M11 7V12M4 4L4.5 13C4.55228 13.5523 4.99772 14 5.5 14H10.5C11.0023 14 11.4477 13.5523 11.5 13L12 4H4Z"
@ -550,14 +550,13 @@
</svg>
Delete
</Button>
{#if status === 'draft'}
<Button variant="secondary" onclick={() => handleSave('published')} disabled={!canSave || isSaving}>
{isSaving ? 'Publishing...' : 'Publish'}
</Button>
{/if}
<Button variant="primary" onclick={() => handleSave()} disabled={!canSave || isSaving}>
{isSaving ? 'Saving...' : 'Save Changes'}
</Button>
<SaveActionsGroup
{status}
onSave={handleSave}
disabled={isSaving}
isLoading={isSaving}
{canSave}
/>
</div>
{/if}
</header>
@ -569,9 +568,7 @@
{:else if error && !album}
<div class="error-container">
<div class="error-message">{error}</div>
<Button variant="secondary" onclick={handleCancel}>
Back to Albums
</Button>
<Button variant="secondary" onclick={handleCancel}>Back to Albums</Button>
</div>
{:else if album}
<div class="album-form">
@ -581,7 +578,7 @@
<div class="form-section">
<h2>Album Details</h2>
<Input
label="Title"
bind:value={title}
@ -644,7 +641,8 @@
<span class="toggle-slider"></span>
<div class="toggle-content">
<span class="toggle-title">Photography Album</span>
<span class="toggle-description">Show this album in the photography experience</span>
<span class="toggle-description">Show this album in the photography experience</span
>
</div>
</label>
</div>
@ -675,30 +673,62 @@
<div class="section-header">
<h2>Photos ({albumPhotos.length})</h2>
<div class="photo-actions">
<Button
variant="secondary"
<Button
variant="secondary"
onclick={() => fileInput.click()}
disabled={isManagingPhotos || isUploading}
>
<svg slot="icon" width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21 15V19C21 20.1046 20.1046 21 19 21H5C3.89543 21 3 20.1046 3 19V15M17 8L12 3M12 3L7 8M12 3V15" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<svg
slot="icon"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M21 15V19C21 20.1046 20.1046 21 19 21H5C3.89543 21 3 20.1046 3 19V15M17 8L12 3M12 3L7 8M12 3V15"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
{isUploading ? 'Uploading...' : 'Upload from Computer'}
</Button>
<Button
variant="secondary"
onclick={() => isMediaLibraryOpen = true}
<Button
variant="secondary"
onclick={() => (isMediaLibraryOpen = true)}
disabled={isManagingPhotos || isUploading}
>
<svg slot="icon" width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21 21L15 15L21 21ZM3 9C3 8.17157 3.67157 7.5 4.5 7.5H19.5C20.3284 7.5 21 8.17157 21 9V18C21 18.8284 20.3284 19.5 19.5 19.5H4.5C3.67157 19.5 3 18.8284 3 18V9Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9 13.5L12 10.5L15 13.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<svg
slot="icon"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M21 21L15 15L21 21ZM3 9C3 8.17157 3.67157 7.5 4.5 7.5H19.5C20.3284 7.5 21 8.17157 21 9V18C21 18.8284 20.3284 19.5 19.5 19.5H4.5C3.67157 19.5 3 18.8284 3 18V9Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M9 13.5L12 10.5L15 13.5"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
Add from Library
</Button>
</div>
</div>
<GalleryUploader
label="Album Photos"
bind:value={albumPhotos}
@ -739,7 +769,7 @@
message="Are you sure you want to delete this album? This action cannot be undone."
confirmText="Delete Album"
onConfirm={handleDelete}
onCancel={() => isDeleteModalOpen = false}
onCancel={() => (isDeleteModalOpen = false)}
/>
<!-- Media Library Modal -->
@ -781,6 +811,7 @@
gap: $unit-2x;
}
.btn-icon {
width: 40px;
height: 40px;
@ -849,7 +880,7 @@
display: flex;
align-items: center;
justify-content: space-between;
h2 {
border-bottom: none;
padding-bottom: 0;
@ -1203,4 +1234,4 @@
}
}
}
</style>
</style>

View file

@ -4,6 +4,7 @@
import Button from '$lib/components/admin/Button.svelte'
import Input from '$lib/components/admin/Input.svelte'
import FormFieldWrapper from '$lib/components/admin/FormFieldWrapper.svelte'
import PublishDropdown from '$lib/components/admin/PublishDropdown.svelte'
// Form state
let title = $state('')
@ -96,6 +97,7 @@
goto('/admin/albums')
}
const canSave = $derived(title.trim().length > 0 && slug.trim().length > 0)
</script>
@ -116,15 +118,43 @@
<h1>New Album</h1>
</div>
<div class="header-actions">
<Button variant="secondary" onclick={handleCancel} disabled={isSaving}>
Cancel
</Button>
<Button variant="ghost" onclick={() => handleSave('draft')} disabled={!canSave || isSaving}>
{isSaving ? 'Saving...' : 'Save Draft'}
</Button>
<Button variant="primary" onclick={() => handleSave('published')} disabled={!canSave || isSaving}>
{isSaving ? 'Publishing...' : 'Publish'}
</Button>
<div class="publish-dropdown">
<Button
variant="primary"
buttonSize="large"
onclick={() => handleSave('published')}
disabled={!canSave || isSaving}
>
{isSaving ? 'Publishing...' : 'Publish'}
</Button>
<Button
variant="ghost"
iconOnly
buttonSize="large"
onclick={(e) => {
e.stopPropagation()
isPublishDropdownOpen = !isPublishDropdownOpen
}}
disabled={isSaving}
>
<svg slot="icon" width="12" height="12" viewBox="0 0 12 12" fill="none">
<path
d="M3 4.5L6 7.5L9 4.5"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</Button>
{#if isPublishDropdownOpen}
<DropdownMenuContainer>
<DropdownItem onclick={() => {handleSave('draft'); isPublishDropdownOpen = false}}>
Save as Draft
</DropdownItem>
</DropdownMenuContainer>
{/if}
</div>
</div>
</header>
@ -248,6 +278,12 @@
gap: $unit-2x;
}
.publish-dropdown {
position: relative;
display: flex;
gap: $unit-half;
}
.btn-icon {
width: 40px;
height: 40px;

View file

@ -19,31 +19,31 @@
<section>
<h2>Sizes</h2>
<div class="button-group">
<Button size="small">Small</Button>
<Button size="medium">Medium</Button>
<Button size="large">Large</Button>
<Button buttonSize="small">Small</Button>
<Button buttonSize="medium">Medium</Button>
<Button buttonSize="large">Large</Button>
</div>
</section>
<section>
<h2>Icon Buttons</h2>
<div class="button-group">
<Button size="small" iconOnly>
<Button buttonSize="small" iconOnly>
<svg slot="icon" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M8 4v8m4-4H4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</Button>
<Button size="medium" iconOnly>
<Button buttonSize="medium" iconOnly>
<svg slot="icon" width="18" height="18" viewBox="0 0 18 18" fill="none">
<path d="M9 5v8m4-4H5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</Button>
<Button size="large" iconOnly>
<Button buttonSize="large" iconOnly>
<svg slot="icon" width="20" height="20" viewBox="0 0 20 20" fill="none">
<path d="M10 6v8m4-4H6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</Button>
<Button size="icon" iconOnly variant="ghost">
<Button buttonSize="icon" iconOnly variant="ghost">
<svg slot="icon" width="18" height="18" viewBox="0 0 18 18" fill="none">
<path d="M6 6l6 6m0-6l-6 6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
@ -81,9 +81,9 @@
<section>
<h2>Square Buttons</h2>
<div class="button-group">
<Button pill={false} size="small">Small Square</Button>
<Button pill={false} buttonSize="small">Small Square</Button>
<Button pill={false}>Medium Square</Button>
<Button pill={false} size="large">Large Square</Button>
<Button pill={false} buttonSize="large">Large Square</Button>
</div>
</section>

View file

@ -103,19 +103,19 @@
<h2>Input Sizes</h2>
<div class="input-group">
<Input
size="small"
buttonSize="small"
label="Small Input"
placeholder="Small size"
/>
<Input
size="medium"
buttonSize="medium"
label="Medium Input"
placeholder="Medium size (default)"
/>
<Input
size="large"
buttonSize="large"
label="Large Input"
placeholder="Large size"
/>

View file

@ -320,7 +320,7 @@
{#snippet actions()}
<Button
variant="secondary"
size="large"
buttonSize="large"
onclick={toggleMultiSelectMode}
class={isMultiSelectMode ? 'active' : ''}
>
@ -329,13 +329,13 @@
</Button>
<Button
variant="secondary"
size="large"
buttonSize="large"
onclick={() => (viewMode = viewMode === 'grid' ? 'list' : 'grid')}
>
{viewMode === 'grid' ? '📋' : '🖼️'}
{viewMode === 'grid' ? 'List' : 'Grid'}
</Button>
<Button variant="primary" size="large" onclick={openUploadModal}>Upload...</Button>
<Button variant="primary" buttonSize="large" onclick={openUploadModal}>Upload...</Button>
{/snippet}
</AdminHeader>
@ -348,14 +348,14 @@
<Select
bind:value={filterType}
options={typeFilterOptions}
size="small"
buttonSize="small"
variant="minimal"
onchange={handleFilterChange}
/>
<Select
bind:value={photographyFilter}
options={photographyFilterOptions}
size="small"
buttonSize="small"
variant="minimal"
onchange={handleFilterChange}
/>
@ -366,7 +366,7 @@
bind:value={searchQuery}
onkeydown={(e) => e.key === 'Enter' && handleSearch()}
placeholder="Search files..."
size="small"
buttonSize="small"
fullWidth={false}
pill={true}
prefixIcon

View file

@ -200,17 +200,17 @@
<div class="file-list-header">
<h3>Files to Upload</h3>
<div class="file-actions">
<Button variant="secondary" size="small" onclick={clearAll} disabled={isUploading}>
<Button variant="secondary" buttonSize="small" onclick={clearAll} disabled={isUploading}>
Clear All
</Button>
<Button
variant="primary"
size="small"
buttonSize="small"
onclick={uploadFiles}
disabled={isUploading || files.length === 0}
>
{#if isUploading}
<LoadingSpinner size="small" />
<LoadingSpinner buttonSize="small" />
Uploading...
{:else}
Upload {files.length} File{files.length !== 1 ? 's' : ''}

View file

@ -162,6 +162,7 @@
isOpen={true}
initialMode="page"
initialPostType="post"
closeOnSave={false}
on:saved={handleComposerSaved}
/>
</div>

View file

@ -6,6 +6,9 @@
import Editor from '$lib/components/admin/Editor.svelte'
import LoadingSpinner from '$lib/components/admin/LoadingSpinner.svelte'
import MetadataPopover from '$lib/components/admin/MetadataPopover.svelte'
import DeleteConfirmationModal from '$lib/components/admin/DeleteConfirmationModal.svelte'
import Button from '$lib/components/admin/Button.svelte'
import SaveActionsGroup from '$lib/components/admin/SaveActionsGroup.svelte'
import type { JSONContent } from '@tiptap/core'
let post = $state<any>(null)
@ -22,9 +25,8 @@
let tags = $state<string[]>([])
let tagInput = $state('')
let showMetadata = $state(false)
let isPublishDropdownOpen = $state(false)
let publishButtonRef: HTMLButtonElement
let metadataButtonRef: HTMLButtonElement
let showDeleteConfirmation = $state(false)
const postTypeConfig = {
post: { icon: '💭', label: 'Post', showTitle: false, showContent: true },
@ -142,9 +144,12 @@
}
}
async function handleDelete() {
if (!confirm('Are you sure you want to delete this post?')) return
function openDeleteConfirmation() {
showMetadata = false
showDeleteConfirmation = true
}
async function handleDelete() {
const auth = localStorage.getItem('admin_auth')
if (!auth) {
goto('/admin/login')
@ -158,6 +163,7 @@
})
if (response.ok) {
showDeleteConfirmation = false
goto('/admin/posts')
}
} catch (error) {
@ -165,11 +171,6 @@
}
}
function handlePublishDropdown(event: MouseEvent) {
if (!publishButtonRef?.contains(event.target as Node)) {
isPublishDropdownOpen = false
}
}
function handleMetadataPopover(event: MouseEvent) {
const target = event.target as Node
@ -183,12 +184,6 @@
showMetadata = false
}
$effect(() => {
if (isPublishDropdownOpen) {
document.addEventListener('click', handlePublishDropdown)
return () => document.removeEventListener('click', handlePublishDropdown)
}
})
$effect(() => {
if (showMetadata) {
@ -244,48 +239,17 @@
bind:tagInput
onAddTag={addTag}
onRemoveTag={removeTag}
onDelete={handleDelete}
onDelete={openDeleteConfirmation}
/>
{/if}
</div>
{#if status === 'draft'}
<div class="publish-dropdown">
<button
bind:this={publishButtonRef}
class="btn btn-primary"
onclick={(e) => {
e.stopPropagation()
isPublishDropdownOpen = !isPublishDropdownOpen
}}
disabled={saving}
>
{saving ? 'Saving...' : 'Publish'}
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
<path
d="M3 4.5L6 7.5L9 4.5"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
{#if isPublishDropdownOpen}
<div class="dropdown-menu">
<button class="dropdown-item" onclick={() => handleSave('published')}>
<span>Publish now</span>
</button>
<button class="dropdown-item" onclick={() => handleSave('draft')}>
<span>Keep as draft</span>
</button>
</div>
{/if}
</div>
{:else}
<button class="btn btn-primary" onclick={() => handleSave()} disabled={saving}>
{saving ? 'Saving...' : 'Save Changes'}
</button>
{/if}
<SaveActionsGroup
{status}
onSave={handleSave}
disabled={saving}
isLoading={saving}
canSave={true}
/>
</div>
{/if}
</header>
@ -319,6 +283,15 @@
{/if}
</AdminPage>
<DeleteConfirmationModal
bind:isOpen={showDeleteConfirmation}
title="Delete Post?"
message="Are you sure you want to delete this post? This action cannot be undone."
confirmText="Delete Post"
onConfirm={handleDelete}
onCancel={() => (showDeleteConfirmation = false)}
/>
<style lang="scss">
@import '$styles/variables.scss';
@ -399,9 +372,6 @@
}
}
.publish-dropdown {
position: relative;
}
.btn {
padding: $unit-2x $unit-3x;
@ -419,15 +389,6 @@
cursor: not-allowed;
}
&.btn-primary {
background-color: $grey-10;
color: white;
&:hover:not(:disabled) {
background-color: $grey-20;
}
}
&.btn-small {
padding: $unit $unit-2x;
font-size: 0.875rem;

View file

@ -5,6 +5,8 @@
import AdminPage from '$lib/components/admin/AdminPage.svelte'
import Editor from '$lib/components/admin/Editor.svelte'
import MetadataPopover from '$lib/components/admin/MetadataPopover.svelte'
import Button from '$lib/components/admin/Button.svelte'
import PublishDropdown from '$lib/components/admin/PublishDropdown.svelte'
import type { JSONContent } from '@tiptap/core'
let loading = $state(false)
@ -19,8 +21,6 @@
let tags = $state<string[]>([])
let tagInput = $state('')
let showMetadata = $state(false)
let isPublishDropdownOpen = $state(false)
let publishButtonRef: HTMLButtonElement
let metadataButtonRef: HTMLButtonElement
const postTypeConfig = {
@ -36,7 +36,7 @@
if (type && ['post', 'essay'].includes(type)) {
postType = type as typeof postType
}
// Generate initial slug based on title
generateSlug()
})
@ -114,11 +114,6 @@
}
}
function handlePublishDropdown(event: MouseEvent) {
if (!publishButtonRef?.contains(event.target as Node)) {
isPublishDropdownOpen = false
}
}
function handleMetadataPopover(event: MouseEvent) {
const target = event.target as Node
@ -132,12 +127,6 @@
showMetadata = false
}
$effect(() => {
if (isPublishDropdownOpen) {
document.addEventListener('click', handlePublishDropdown)
return () => document.removeEventListener('click', handlePublishDropdown)
}
})
$effect(() => {
if (showMetadata) {
@ -204,38 +193,12 @@
/>
{/if}
</div>
<div class="publish-dropdown">
<button
bind:this={publishButtonRef}
class="btn btn-primary"
onclick={(e) => {
e.stopPropagation()
isPublishDropdownOpen = !isPublishDropdownOpen
}}
disabled={saving}
>
{saving ? 'Saving...' : 'Publish'}
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
<path
d="M3 4.5L6 7.5L9 4.5"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
{#if isPublishDropdownOpen}
<div class="dropdown-menu">
<button class="dropdown-item" onclick={() => handleSave('published')}>
<span>Publish now</span>
</button>
<button class="dropdown-item" onclick={() => handleSave('draft')}>
<span>Save as draft</span>
</button>
</div>
{/if}
</div>
<PublishDropdown
onPublish={() => handleSave('published')}
onSaveDraft={() => handleSave('draft')}
disabled={saving}
isLoading={saving}
/>
</div>
</header>
@ -307,9 +270,6 @@
}
}
.publish-dropdown {
position: relative;
}
.btn {
padding: $unit-2x $unit-3x;
@ -326,15 +286,6 @@
opacity: 0.6;
cursor: not-allowed;
}
&.btn-primary {
background-color: $grey-10;
color: white;
&:hover:not(:disabled) {
background-color: $grey-20;
}
}
}
.dropdown-menu {

View file

@ -31,7 +31,6 @@
let error = $state('')
let showDeleteModal = $state(false)
let projectToDelete = $state<Project | null>(null)
let activeDropdown = $state<number | null>(null)
let statusCounts = $state<Record<string, number>>({})
// Filter state
@ -53,7 +52,7 @@
onMount(async () => {
await loadProjects()
// Close dropdown when clicking outside
// Handle clicks outside dropdowns
document.addEventListener('click', handleOutsideClick)
return () => document.removeEventListener('click', handleOutsideClick)
})
@ -61,7 +60,8 @@
function handleOutsideClick(event: MouseEvent) {
const target = event.target as HTMLElement
if (!target.closest('.dropdown-container')) {
activeDropdown = null
// Close any open dropdowns by telling all ProjectListItems
document.dispatchEvent(new CustomEvent('closeDropdowns'))
}
}
@ -106,20 +106,11 @@
}
}
function handleToggleDropdown(event: CustomEvent<{ projectId: number; event: MouseEvent }>) {
event.detail.event.stopPropagation()
activeDropdown = activeDropdown === event.detail.projectId ? null : event.detail.projectId
}
function handleEdit(event: CustomEvent<{ project: Project; event: MouseEvent }>) {
event.detail.event.stopPropagation()
function handleEdit(event: CustomEvent<{ project: Project }>) {
goto(`/admin/projects/${event.detail.project.id}/edit`)
}
async function handleTogglePublish(event: CustomEvent<{ project: Project; event: MouseEvent }>) {
event.detail.event.stopPropagation()
activeDropdown = null
async function handleTogglePublish(event: CustomEvent<{ project: Project }>) {
const project = event.detail.project
try {
@ -143,9 +134,7 @@
}
}
function handleDelete(event: CustomEvent<{ project: Project; event: MouseEvent }>) {
event.detail.event.stopPropagation()
activeDropdown = null
function handleDelete(event: CustomEvent<{ project: Project }>) {
projectToDelete = event.detail.project
showDeleteModal = true
}
@ -205,7 +194,7 @@
<AdminPage>
<AdminHeader title="Projects" slot="header">
{#snippet actions()}
<Button variant="primary" size="large" href="/admin/projects/new">New Project</Button>
<Button variant="primary" buttonSize="large" href="/admin/projects/new">New Project</Button>
{/snippet}
</AdminHeader>
@ -253,8 +242,6 @@
{#each filteredProjects as project}
<ProjectListItem
{project}
isDropdownActive={activeDropdown === project.id}
ontoggleDropdown={handleToggleDropdown}
onedit={handleEdit}
ontogglePublish={handleTogglePublish}
ondelete={handleDelete}

View file

@ -67,6 +67,20 @@ export const POST: RequestHandler = async (event) => {
try {
const data = await event.request.json()
// Generate slug if not provided
if (!data.slug) {
if (data.title) {
// Generate slug from title
data.slug = data.title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
} else {
// Generate timestamp-based slug for posts without titles
data.slug = `post-${Date.now()}`
}
}
// Set publishedAt if status is published
if (data.status === 'published') {
data.publishedAt = new Date()

View file

@ -35,10 +35,10 @@
<span class="welcome">
Welcome, <b>{user.name}</b>!
</span>
<Button size="small" onclick={onLogout} label="Log out" />
<Button buttonSize="small" onclick={onLogout} label="Log out" />
{:else}
<Button size="small" onclick={onLogin} label="Log in" />
<Button primary size="small" onclick={onCreateAccount} label="Sign up" />
<Button buttonSize="small" onclick={onLogin} label="Log in" />
<Button primary buttonSize="small" onclick={onCreateAccount} label="Sign up" />
{/if}
</div>
</div>

View file

@ -20,9 +20,9 @@
<div class="section">
<h4>Sizes</h4>
<div class="button-grid">
<Button variant="primary" size="small">Small</Button>
<Button variant="primary" size="medium">Medium</Button>
<Button variant="primary" size="large">Large</Button>
<Button variant="primary" buttonSize="small">Small</Button>
<Button variant="primary" buttonSize="medium">Medium</Button>
<Button variant="primary" buttonSize="large">Large</Button>
</div>
</div>