Fixes for UniverseComposer and delete modals.
Also standardizing Publish buttons and whatnot
This commit is contained in:
parent
e7a7e7cd1e
commit
b2ad9efd9c
39 changed files with 933 additions and 464 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@
|
|||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
z-index: 1050;
|
||||
}
|
||||
|
||||
.modal {
|
||||
|
|
|
|||
59
src/lib/components/admin/DropdownItem.svelte
Normal file
59
src/lib/components/admin/DropdownItem.svelte
Normal 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>
|
||||
134
src/lib/components/admin/DropdownMenu.svelte
Normal file
134
src/lib/components/admin/DropdownMenu.svelte
Normal 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>
|
||||
28
src/lib/components/admin/DropdownMenuContainer.svelte
Normal file
28
src/lib/components/admin/DropdownMenuContainer.svelte
Normal 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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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' : ''}
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@
|
|||
<Button
|
||||
bind:this={buttonRef}
|
||||
variant="primary"
|
||||
size="large"
|
||||
buttonSize="large"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation()
|
||||
isOpen = !isOpen
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
107
src/lib/components/admin/PublishDropdown.svelte
Normal file
107
src/lib/components/admin/PublishDropdown.svelte
Normal 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>
|
||||
68
src/lib/components/admin/SaveActionsGroup.svelte
Normal file
68
src/lib/components/admin/SaveActionsGroup.svelte
Normal 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}
|
||||
118
src/lib/components/admin/StatusDropdown.svelte
Normal file
118
src/lib/components/admin/StatusDropdown.svelte
Normal 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>
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 'What’s the title?'
|
||||
} else if (node.type.name === 'paragraph') {
|
||||
return 'Press / or write something ...'
|
||||
return placeholder || 'Press / or write something ...'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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' : ''}
|
||||
|
|
|
|||
|
|
@ -162,6 +162,7 @@
|
|||
isOpen={true}
|
||||
initialMode="page"
|
||||
initialPostType="post"
|
||||
closeOnSave={false}
|
||||
on:saved={handleComposerSaved}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue