Prettier + build errors

This commit is contained in:
Justin Edmund 2025-06-02 08:41:03 -07:00
parent 2a6291a547
commit 78443e2bdd
127 changed files with 2873 additions and 2292 deletions

View file

@ -1,30 +1,23 @@
import type { StorybookConfig } from '@storybook/sveltekit'; import type { StorybookConfig } from '@storybook/sveltekit'
import { mergeConfig } from 'vite'; import { mergeConfig } from 'vite'
import path from 'path'; import path from 'path'
const config: StorybookConfig = { const config: StorybookConfig = {
stories: [ stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|ts|svelte)'],
"../src/**/*.mdx", addons: ['@storybook/addon-svelte-csf', '@storybook/addon-docs', '@storybook/addon-a11y'],
"../src/**/*.stories.@(js|ts|svelte)"
],
addons: [
"@storybook/addon-svelte-csf",
"@storybook/addon-docs",
"@storybook/addon-a11y"
],
framework: { framework: {
name: "@storybook/sveltekit", name: '@storybook/sveltekit',
options: {} options: {}
}, },
viteFinal: async (config) => { viteFinal: async (config) => {
return mergeConfig(config, { return mergeConfig(config, {
resolve: { resolve: {
alias: { alias: {
'$lib': path.resolve('./src/lib'), $lib: path.resolve('./src/lib'),
'$components': path.resolve('./src/lib/components'), $components: path.resolve('./src/lib/components'),
'$icons': path.resolve('./src/assets/icons'), $icons: path.resolve('./src/assets/icons'),
'$illos': path.resolve('./src/assets/illos'), $illos: path.resolve('./src/assets/illos'),
'$styles': path.resolve('./src/assets/styles') $styles: path.resolve('./src/assets/styles')
} }
}, },
css: { css: {
@ -39,8 +32,8 @@ const config: StorybookConfig = {
} }
} }
} }
}); })
} }
}; }
export default config; export default config

View file

@ -1,6 +1,6 @@
import type { Preview } from '@storybook/sveltekit'; import type { Preview } from '@storybook/sveltekit'
import '../src/assets/styles/reset.css'; import '../src/assets/styles/reset.css'
import '../src/assets/styles/globals.scss'; import '../src/assets/styles/globals.scss'
const preview: Preview = { const preview: Preview = {
parameters: { parameters: {
@ -8,8 +8,8 @@ const preview: Preview = {
controls: { controls: {
matchers: { matchers: {
color: /(background|color)$/i, color: /(background|color)$/i,
date: /Date$/, date: /Date$/
}, }
}, },
backgrounds: { backgrounds: {
default: 'light', default: 'light',
@ -17,8 +17,8 @@ const preview: Preview = {
{ name: 'light', value: '#ffffff' }, { name: 'light', value: '#ffffff' },
{ name: 'dark', value: '#333333' }, { name: 'dark', value: '#333333' },
{ name: 'admin', value: '#f5f5f5' }, { name: 'admin', value: '#f5f5f5' },
{ name: 'grey-95', value: '#f8f9fa' }, { name: 'grey-95', value: '#f8f9fa' }
], ]
}, },
viewport: { viewport: {
viewports: { viewports: {
@ -33,10 +33,10 @@ const preview: Preview = {
desktop: { desktop: {
name: 'Desktop', name: 'Desktop',
styles: { width: '1440px', height: '900px' } styles: { width: '1440px', height: '900px' }
}, }
}, }
}, }
}, }
}; }
export default preview; export default preview

View file

@ -38,7 +38,6 @@ We are using Svelte 5 in Runes mode, so make sure to only write solutions that w
Make sure to use the CSS variables that are defined across the various files in `src/assets/styles`. When making new colors or defining new variables, check that it doesn't exist first, then define it. Make sure to use the CSS variables that are defined across the various files in `src/assets/styles`. When making new colors or defining new variables, check that it doesn't exist first, then define it.
### Key Architecture Components ### Key Architecture Components
**API Integration Layer** (`src/routes/api/`) **API Integration Layer** (`src/routes/api/`)

View file

@ -1,5 +1,5 @@
// For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format // For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format
import storybook from "eslint-plugin-storybook"; import storybook from 'eslint-plugin-storybook'
import js from '@eslint/js' import js from '@eslint/js'
import ts from 'typescript-eslint' import ts from 'typescript-eslint'
@ -33,5 +33,5 @@ export default [
{ {
ignores: ['build/', '.svelte-kit/', 'dist/'] ignores: ['build/', '.svelte-kit/', 'dist/']
}, },
...storybook.configs["flat/recommended"] ...storybook.configs['flat/recommended']
]; ]

View file

@ -101,7 +101,8 @@ async function main() {
slug: 'granblue-team', slug: 'granblue-team',
title: 'granblue.team', title: 'granblue.team',
subtitle: 'Comprehensive web app for Granblue Fantasy players', subtitle: 'Comprehensive web app for Granblue Fantasy players',
description: 'A comprehensive web application for Granblue Fantasy players to track raids, manage crews, and optimize team compositions. Features real-time raid tracking, character databases, and community tools.', description:
'A comprehensive web application for Granblue Fantasy players to track raids, manage crews, and optimize team compositions. Features real-time raid tracking, character databases, and community tools.',
year: 2022, year: 2022,
client: 'Personal Project', client: 'Personal Project',
role: 'Full-Stack Developer', role: 'Full-Stack Developer',
@ -119,7 +120,8 @@ async function main() {
slug: 'subway-board', slug: 'subway-board',
title: 'Subway Board', title: 'Subway Board',
subtitle: 'Beautiful, minimalist NYC subway dashboard', subtitle: 'Beautiful, minimalist NYC subway dashboard',
description: 'A beautiful, minimalist dashboard displaying real-time NYC subway arrival times. Clean interface inspired by the classic subway map design with live MTA data integration.', description:
'A beautiful, minimalist dashboard displaying real-time NYC subway arrival times. Clean interface inspired by the classic subway map design with live MTA data integration.',
year: 2023, year: 2023,
client: 'Personal Project', client: 'Personal Project',
role: 'Developer & Designer', role: 'Developer & Designer',
@ -136,7 +138,8 @@ async function main() {
slug: 'siero-discord', slug: 'siero-discord',
title: 'Siero for Discord', title: 'Siero for Discord',
subtitle: 'Discord bot for Granblue Fantasy communities', subtitle: 'Discord bot for Granblue Fantasy communities',
description: 'A Discord bot for Granblue Fantasy communities providing character lookups, raid notifications, and server management tools. Serves thousands of users across multiple servers.', description:
'A Discord bot for Granblue Fantasy communities providing character lookups, raid notifications, and server management tools. Serves thousands of users across multiple servers.',
year: 2021, year: 2021,
client: 'Personal Project', client: 'Personal Project',
role: 'Bot Developer', role: 'Bot Developer',
@ -153,7 +156,8 @@ async function main() {
slug: 'homelab', slug: 'homelab',
title: 'Homelab', title: 'Homelab',
subtitle: 'Self-hosted infrastructure on Kubernetes', subtitle: 'Self-hosted infrastructure on Kubernetes',
description: 'Self-hosted infrastructure running on Kubernetes with monitoring, media servers, and development environments. Includes automated deployments and backup strategies.', description:
'Self-hosted infrastructure running on Kubernetes with monitoring, media servers, and development environments. Includes automated deployments and backup strategies.',
year: 2023, year: 2023,
client: 'Personal Project', client: 'Personal Project',
role: 'DevOps Engineer', role: 'DevOps Engineer',
@ -181,11 +185,13 @@ async function main() {
{ type: 'paragraph', content: 'This is my first essay on the new CMS!' }, { type: 'paragraph', content: 'This is my first essay on the new CMS!' },
{ {
type: 'paragraph', type: 'paragraph',
content: 'The system now uses a simplified post type system with just essays and posts.' content:
'The system now uses a simplified post type system with just essays and posts.'
}, },
{ {
type: 'paragraph', type: 'paragraph',
content: 'Essays are perfect for longer-form content with titles and excerpts, while posts are great for quick thoughts and updates.' content:
'Essays are perfect for longer-form content with titles and excerpts, while posts are great for quick thoughts and updates.'
} }
] ]
}, },
@ -203,7 +209,8 @@ async function main() {
blocks: [ blocks: [
{ {
type: 'paragraph', type: 'paragraph',
content: 'Just pushed a major update to the site. The new simplified post types are working great! 🎉' content:
'Just pushed a major update to the site. The new simplified post types are working great! 🎉'
} }
] ]
}, },
@ -221,7 +228,8 @@ async function main() {
blocks: [ blocks: [
{ {
type: 'paragraph', type: 'paragraph',
content: 'Design systems have become essential for maintaining consistency across large products.' content:
'Design systems have become essential for maintaining consistency across large products.'
}, },
{ {
type: 'paragraph', type: 'paragraph',
@ -229,7 +237,8 @@ async function main() {
}, },
{ {
type: 'paragraph', type: 'paragraph',
content: 'Too rigid, and designers feel boxed in. Too flexible, and you lose consistency.' content:
'Too rigid, and designers feel boxed in. Too flexible, and you lose consistency.'
} }
] ]
}, },
@ -264,7 +273,8 @@ async function main() {
blocks: [ blocks: [
{ {
type: 'paragraph', type: 'paragraph',
content: 'Built a small CLI tool over the weekend. Sometimes the best projects come from scratching your own itch.' content:
'Built a small CLI tool over the weekend. Sometimes the best projects come from scratching your own itch.'
} }
] ]
}, },

View file

@ -17,7 +17,7 @@ export function getAuthHeaders(): HeadersInit {
} }
return { return {
'Authorization': `Basic ${adminCredentials}` Authorization: `Basic ${adminCredentials}`
} }
} }
@ -32,7 +32,10 @@ export function clearAuth() {
} }
// Make authenticated API request // Make authenticated API request
export async function authenticatedFetch(url: string, options: RequestInit = {}): Promise<Response> { export async function authenticatedFetch(
url: string,
options: RequestInit = {}
): Promise<Response> {
const headers = { const headers = {
...getAuthHeaders(), ...getAuthHeaders(),
...options.headers ...options.headers

View file

@ -14,9 +14,12 @@
const getPostTypeLabel = (postType: string) => { const getPostTypeLabel = (postType: string) => {
switch (postType) { switch (postType) {
case 'post': return 'Post' case 'post':
case 'essay': return 'Essay' return 'Post'
default: return 'Post' case 'essay':
return 'Essay'
default:
return 'Post'
} }
} }
@ -42,18 +45,22 @@
case 'bulletList': case 'bulletList':
case 'ul': case 'ul':
const listItems = (block.content || []).map((item: any) => { const listItems = (block.content || [])
.map((item: any) => {
const itemText = item.content || item.text || '' const itemText = item.content || item.text || ''
return `<li>${itemText}</li>` return `<li>${itemText}</li>`
}).join('') })
.join('')
return `<ul>${listItems}</ul>` return `<ul>${listItems}</ul>`
case 'orderedList': case 'orderedList':
case 'ol': case 'ol':
const orderedItems = (block.content || []).map((item: any) => { const orderedItems = (block.content || [])
.map((item: any) => {
const itemText = item.content || item.text || '' const itemText = item.content || item.text || ''
return `<li>${itemText}</li>` return `<li>${itemText}</li>`
}).join('') })
.join('')
return `<ol>${orderedItems}</ol>` return `<ol>${orderedItems}</ol>`
case 'blockquote': case 'blockquote':
@ -110,11 +117,13 @@
{#if post.linkUrl} {#if post.linkUrl}
<div class="post-link-preview"> <div class="post-link-preview">
<LinkCard link={{ <LinkCard
link={{
url: post.linkUrl, url: post.linkUrl,
title: post.title, title: post.title,
description: post.linkDescription description: post.linkDescription
}} /> }}
/>
</div> </div>
{/if} {/if}
@ -316,7 +325,8 @@
background: $grey-95; background: $grey-95;
padding: 2px 6px; padding: 2px 6px;
border-radius: 4px; border-radius: 4px;
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace; font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New',
monospace;
font-size: 0.9em; font-size: 0.9em;
color: $grey-10; color: $grey-10;
} }

View file

@ -38,9 +38,18 @@
{#if project.status === 'password-protected'} {#if project.status === 'password-protected'}
<div class="status-indicator password-protected"> <div class="status-indicator password-protected">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" stroke="currentColor" stroke-width="2"/> <rect
<circle cx="12" cy="16" r="1" fill="currentColor"/> x="3"
<path d="M7 11V7a5 5 0 0 1 10 0v4" stroke="currentColor" stroke-width="2"/> 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" />
</svg> </svg>
<span>Password Protected</span> <span>Password Protected</span>
</div> </div>
@ -81,8 +90,20 @@
{#if project.status === 'list-only'} {#if project.status === 'list-only'}
<div class="status-indicator list-only"> <div class="status-indicator list-only">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> <path
<path d="M1 1l22 22" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M1 1l22 22"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg> </svg>
<span>View Only</span> <span>View Only</span>
</div> </div>

View file

@ -32,7 +32,7 @@
error = '' error = ''
// Simulate a small delay for better UX // Simulate a small delay for better UX
await new Promise(resolve => setTimeout(resolve, 500)) await new Promise((resolve) => setTimeout(resolve, 500))
if (password === correctPassword) { if (password === correctPassword) {
// Store in session storage // Store in session storage
@ -63,7 +63,13 @@
{#snippet passwordHeader()} {#snippet passwordHeader()}
<div class="password-header"> <div class="password-header">
<div class="lock-icon"> <div class="lock-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg
width="48"
height="48"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path <path
d="M18 11H6C5.45 11 5 11.45 5 12V19C5 19.55 5.45 20 6 20H18C18.55 20 19 19.55 19 19V12C19 11.45 18.55 11 18 11Z" d="M18 11H6C5.45 11 5 11.45 5 12V19C5 19.55 5.45 20 6 20H18C18.55 20 19 19.55 19 19V12C19 11.45 18.55 11 18 11Z"
stroke="currentColor" stroke="currentColor"
@ -191,7 +197,9 @@
border: 1px solid $grey-80; border: 1px solid $grey-80;
border-radius: $unit; border-radius: $unit;
font-size: 1rem; font-size: 1rem;
transition: border-color 0.2s ease, box-shadow 0.2s ease; transition:
border-color 0.2s ease,
box-shadow 0.2s ease;
&:focus { &:focus {
outline: none; outline: none;

View file

@ -17,9 +17,7 @@
<article class="universe-album-card"> <article class="universe-album-card">
<div class="card-content"> <div class="card-content">
<div class="card-header"> <div class="card-header">
<div class="album-type-badge"> <div class="album-type-badge">Album</div>
Album
</div>
<time class="album-date" datetime={album.publishedAt}> <time class="album-date" datetime={album.publishedAt}>
{formatDate(album.publishedAt)} {formatDate(album.publishedAt)}
</time> </time>
@ -60,9 +58,7 @@
</div> </div>
<div class="card-footer"> <div class="card-footer">
<a href="/photos/{album.slug}" class="view-album"> <a href="/photos/{album.slug}" class="view-album"> View album → </a>
View album →
</a>
<UniverseIcon class="universe-icon" /> <UniverseIcon class="universe-icon" />
</div> </div>
</div> </div>

View file

@ -15,9 +15,12 @@
const getPostTypeLabel = (postType: string) => { const getPostTypeLabel = (postType: string) => {
switch (postType) { switch (postType) {
case 'post': return 'Post' case 'post':
case 'essay': return 'Essay' return 'Post'
default: return 'Post' case 'essay':
return 'Essay'
default:
return 'Post'
} }
} }
@ -85,9 +88,7 @@
{/if} {/if}
<div class="card-footer"> <div class="card-footer">
<a href="/universe/{post.slug}" class="read-more"> <a href="/universe/{post.slug}" class="read-more"> Read more → </a>
Read more →
</a>
<UniverseIcon class="universe-icon" /> <UniverseIcon class="universe-icon" />
</div> </div>
</div> </div>

View file

@ -40,7 +40,11 @@
$effect(() => { $effect(() => {
if (initialData && mode === 'edit') { if (initialData && mode === 'edit') {
// Parse album content structure // Parse album content structure
if (initialData.content && typeof initialData.content === 'object' && 'type' in initialData.content) { if (
initialData.content &&
typeof initialData.content === 'object' &&
'type' in initialData.content
) {
const albumContent = initialData.content as any const albumContent = initialData.content as any
if (albumContent.type === 'album') { if (albumContent.type === 'album') {
// Album content structure: { type: 'album', gallery: [mediaIds], description: JSONContent } // Album content structure: { type: 'album', gallery: [mediaIds], description: JSONContent }
@ -80,7 +84,7 @@
}) })
const mediaResults = await Promise.all(mediaPromises) const mediaResults = await Promise.all(mediaPromises)
gallery = mediaResults.filter(media => media !== null) gallery = mediaResults.filter((media) => media !== null)
} catch (error) { } catch (error) {
console.error('Failed to load gallery media:', error) console.error('Failed to load gallery media:', error)
} }
@ -114,9 +118,9 @@
postType: 'album', postType: 'album',
status: newStatus, status: newStatus,
content, content,
gallery: gallery.map(media => media.id), gallery: gallery.map((media) => media.id),
featuredImage: gallery.length > 0 ? gallery[0].id : undefined, featuredImage: gallery.length > 0 ? gallery[0].id : undefined,
tags: tags.trim() ? tags.split(',').map(tag => tag.trim()) : [] tags: tags.trim() ? tags.split(',').map((tag) => tag.trim()) : []
} }
const url = mode === 'edit' ? `/api/posts/${postId}` : '/api/posts' const url = mode === 'edit' ? `/api/posts/${postId}` : '/api/posts'
@ -197,19 +201,23 @@
</div> </div>
<div class="header-actions"> <div class="header-actions">
{#if mode === 'create'} {#if mode === 'create'}
<Button variant="secondary" onclick={handleCancel} disabled={isSaving}> <Button variant="secondary" onclick={handleCancel} disabled={isSaving}>Cancel</Button>
Cancel <Button
</Button> variant="secondary"
<Button variant="secondary" onclick={() => handleSave('draft')} disabled={!isValid || isSaving}> onclick={() => handleSave('draft')}
disabled={!isValid || isSaving}
>
{isSaving ? 'Saving...' : 'Save Draft'} {isSaving ? 'Saving...' : 'Save Draft'}
</Button> </Button>
<Button variant="primary" onclick={() => handleSave('published')} disabled={!isValid || isSaving}> <Button
variant="primary"
onclick={() => handleSave('published')}
disabled={!isValid || isSaving}
>
{isSaving ? 'Publishing...' : 'Publish Album'} {isSaving ? 'Publishing...' : 'Publish Album'}
</Button> </Button>
{:else} {:else}
<Button variant="secondary" onclick={handleCancel} disabled={isSaving}> <Button variant="secondary" onclick={handleCancel} disabled={isSaving}>Cancel</Button>
Cancel
</Button>
<Button variant="primary" onclick={() => handleSave()} disabled={!isValid || isSaving}> <Button variant="primary" onclick={() => handleSave()} disabled={!isValid || isSaving}>
{isSaving ? 'Saving...' : 'Save Changes'} {isSaving ? 'Saving...' : 'Save Changes'}
</Button> </Button>

View file

@ -90,7 +90,7 @@
// Get thumbnail - try cover photo first, then first photo // Get thumbnail - try cover photo first, then first photo
function getThumbnailUrl(): string | null { function getThumbnailUrl(): string | null {
if (album.coverPhotoId && album.photos.length > 0) { if (album.coverPhotoId && album.photos.length > 0) {
const coverPhoto = album.photos.find(p => p.id === album.coverPhotoId) const coverPhoto = album.photos.find((p) => p.id === album.coverPhotoId)
if (coverPhoto) { if (coverPhoto) {
return coverPhoto.thumbnailUrl || coverPhoto.url return coverPhoto.thumbnailUrl || coverPhoto.url
} }

View file

@ -5,12 +5,7 @@
disabled?: boolean disabled?: boolean
} }
let { let { onclick, variant = 'default', disabled = false, children }: Props = $props()
onclick,
variant = 'default',
disabled = false,
children
}: Props = $props()
function handleClick(event: MouseEvent) { function handleClick(event: MouseEvent) {
if (disabled) return if (disabled) return

View file

@ -17,12 +17,7 @@
divider?: boolean divider?: boolean
} }
let { let { isOpen = $bindable(), triggerElement, items, onClose }: Props = $props()
isOpen = $bindable(),
triggerElement,
items,
onClose
}: Props = $props()
let dropdownElement: HTMLDivElement let dropdownElement: HTMLDivElement
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()

View file

@ -212,7 +212,9 @@
if (!clipboardData) return false if (!clipboardData) return false
// Check for images first // Check for images first
const imageItem = Array.from(clipboardData.items).find(item => item.type.indexOf('image') === 0) const imageItem = Array.from(clipboardData.items).find(
(item) => item.type.indexOf('image') === 0
)
if (imageItem) { if (imageItem) {
const file = imageItem.getAsFile() const file = imageItem.getAsFile()
if (!file) return false if (!file) return false

View file

@ -264,18 +264,9 @@
}} }}
> >
<div class="form-section"> <div class="form-section">
<Input <Input label="Title" bind:value={title} required placeholder="Essay title" />
label="Title"
bind:value={title}
required
placeholder="Essay title"
/>
<Input <Input label="Slug" bind:value={slug} placeholder="essay-url-slug" />
label="Slug"
bind:value={slug}
placeholder="essay-url-slug"
/>
<Input <Input
type="textarea" type="textarea"
@ -366,7 +357,6 @@
} }
} }
.admin-container { .admin-container {
width: 100%; width: 100%;
margin: 0 auto; margin: 0 auto;
@ -383,7 +373,6 @@
display: flex; display: flex;
} }
// Custom styles for save/publish buttons to maintain grey color scheme // Custom styles for save/publish buttons to maintain grey color scheme
:global(.save-button.btn-primary) { :global(.save-button.btn-primary) {
background-color: $grey-10; background-color: $grey-10;

View file

@ -27,8 +27,8 @@
function handleImagesSelect(media: Media[]) { function handleImagesSelect(media: Media[]) {
// Add new images to existing ones, avoiding duplicates // Add new images to existing ones, avoiding duplicates
const existingIds = new Set(value.map(item => item.id)) const existingIds = new Set(value.map((item) => item.id))
const newImages = media.filter(item => !existingIds.has(item.id)) const newImages = media.filter((item) => !existingIds.has(item.id))
if (maxItems) { if (maxItems) {
const availableSlots = maxItems - value.length const availableSlots = maxItems - value.length
@ -117,10 +117,8 @@
// Computed properties // Computed properties
const hasImages = $derived(value.length > 0) const hasImages = $derived(value.length > 0)
const canAddMore = $derived(!maxItems || value.length < maxItems) const canAddMore = $derived(!maxItems || value.length < maxItems)
const selectedIds = $derived(value.map(item => item.id)) const selectedIds = $derived(value.map((item) => item.id))
const itemsText = $derived( const itemsText = $derived(value.length === 1 ? '1 image' : `${value.length} images`)
value.length === 1 ? '1 image' : `${value.length} images`
)
</script> </script>
<div class="gallery-manager"> <div class="gallery-manager">
@ -158,13 +156,19 @@
> >
<!-- Drag Handle --> <!-- Drag Handle -->
<div class="drag-handle"> <div class="drag-handle">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg
<circle cx="9" cy="12" r="1" fill="currentColor"/> width="12"
<circle cx="9" cy="5" r="1" fill="currentColor"/> height="12"
<circle cx="9" cy="19" r="1" fill="currentColor"/> viewBox="0 0 24 24"
<circle cx="15" cy="12" r="1" fill="currentColor"/> fill="none"
<circle cx="15" cy="5" r="1" fill="currentColor"/> xmlns="http://www.w3.org/2000/svg"
<circle cx="15" cy="19" r="1" fill="currentColor"/> >
<circle cx="9" cy="12" r="1" fill="currentColor" />
<circle cx="9" cy="5" r="1" fill="currentColor" />
<circle cx="9" cy="19" r="1" fill="currentColor" />
<circle cx="15" cy="12" r="1" fill="currentColor" />
<circle cx="15" cy="5" r="1" fill="currentColor" />
<circle cx="15" cy="19" r="1" fill="currentColor" />
</svg> </svg>
</div> </div>
@ -174,10 +178,29 @@
<img src={item.thumbnailUrl} alt={item.filename} /> <img src={item.thumbnailUrl} alt={item.filename} />
{:else} {:else}
<div class="image-placeholder"> <div class="image-placeholder">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg
<rect x="3" y="5" width="18" height="14" rx="2" stroke="currentColor" stroke-width="2"/> width="24"
<circle cx="8.5" cy="8.5" r=".5" fill="currentColor"/> height="24"
<path d="M3 16l5-5 3 3 4-4 4 4" stroke="currentColor" stroke-width="2" fill="none"/> viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect
x="3"
y="5"
width="18"
height="14"
rx="2"
stroke="currentColor"
stroke-width="2"
/>
<circle cx="8.5" cy="8.5" r=".5" fill="currentColor" />
<path
d="M3 16l5-5 3 3 4-4 4 4"
stroke="currentColor"
stroke-width="2"
fill="none"
/>
</svg> </svg>
</div> </div>
{/if} {/if}
@ -198,7 +221,13 @@
onclick={() => removeImage(index)} onclick={() => removeImage(index)}
aria-label="Remove image" aria-label="Remove image"
> >
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path <path
d="M6 6L18 18M6 18L18 6" d="M6 6L18 18M6 18L18 6"
stroke="currentColor" stroke="currentColor"
@ -217,13 +246,15 @@
<!-- Add More Button (if within grid) --> <!-- Add More Button (if within grid) -->
{#if canAddMore} {#if canAddMore}
<button <button type="button" class="add-more-item" onclick={openModal}>
type="button"
class="add-more-item"
onclick={openModal}
>
<div class="add-icon"> <div class="add-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path <path
d="M12 5v14m-7-7h14" d="M12 5v14m-7-7h14"
stroke="currentColor" stroke="currentColor"
@ -241,10 +272,24 @@
<div class="empty-state" class:has-error={error}> <div class="empty-state" class:has-error={error}>
<div class="empty-content"> <div class="empty-content">
<div class="empty-icon"> <div class="empty-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg
<rect x="3" y="5" width="18" height="14" rx="2" stroke="currentColor" stroke-width="1.5"/> width="48"
<circle cx="8.5" cy="8.5" r=".5" fill="currentColor"/> height="48"
<path d="M3 16l5-5 3 3 4-4 4 4" stroke="currentColor" stroke-width="1.5" fill="none"/> viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect
x="3"
y="5"
width="18"
height="14"
rx="2"
stroke="currentColor"
stroke-width="1.5"
/>
<circle cx="8.5" cy="8.5" r=".5" fill="currentColor" />
<path d="M3 16l5-5 3 3 4-4 4 4" stroke="currentColor" stroke-width="1.5" fill="none" />
</svg> </svg>
</div> </div>
<p class="empty-text">No images added yet</p> <p class="empty-text">No images added yet</p>

View file

@ -135,10 +135,7 @@
const handleClickOutside = (event: MouseEvent) => { const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node const target = event.target as Node
// Don't close if clicking inside the trigger button or the popover itself // Don't close if clicking inside the trigger button or the popover itself
if ( if (triggerElement?.contains(target) || popoverElement?.contains(target)) {
triggerElement?.contains(target) ||
popoverElement?.contains(target)
) {
return return
} }
onClose() onClose()
@ -221,7 +218,7 @@
label={field.label} label={field.label}
bind:value={data.tagInput} bind:value={data.tagInput}
onkeydown={(e) => e.key === 'Enter' && (e.preventDefault(), onAddTag())} onkeydown={(e) => e.key === 'Enter' && (e.preventDefault(), onAddTag())}
placeholder={field.placeholder || "Add tags..."} placeholder={field.placeholder || 'Add tags...'}
/> />
<button type="button" onclick={onAddTag} class="add-tag-btn">Add</button> <button type="button" onclick={onAddTag} class="add-tag-btn">Add</button>
@ -459,7 +456,6 @@
} }
} }
@include breakpoint('phone') { @include breakpoint('phone') {
.metadata-popover { .metadata-popover {
min-width: 280px; min-width: 280px;

View file

@ -57,9 +57,7 @@
? 'aspect-ratio: 16/9;' ? 'aspect-ratio: 16/9;'
: (() => { : (() => {
const [width, height] = aspectRatio.split(':').map(Number) const [width, height] = aspectRatio.split(':').map(Number)
return width && height return width && height ? `aspect-ratio: ${width}/${height};` : 'aspect-ratio: 16/9;'
? `aspect-ratio: ${width}/${height};`
: 'aspect-ratio: 16/9;'
})() })()
) )
</script> </script>
@ -82,16 +80,12 @@
tabindex="0" tabindex="0"
onclick={openModal} onclick={openModal}
onkeydown={(e) => e.key === 'Enter' && openModal()} onkeydown={(e) => e.key === 'Enter' && openModal()}
onmouseenter={() => isHovering = true} onmouseenter={() => (isHovering = true)}
onmouseleave={() => isHovering = false} onmouseleave={() => (isHovering = false)}
> >
{#if hasImage && value} {#if hasImage && value}
<!-- Image Display --> <!-- Image Display -->
<img <img src={value.url} alt={value.filename} class="preview-image" />
src={value.url}
alt={value.filename}
class="preview-image"
/>
<!-- Hover Overlay --> <!-- Hover Overlay -->
{#if isHovering} {#if isHovering}
@ -149,10 +143,24 @@
<!-- Empty State --> <!-- Empty State -->
<div class="empty-state"> <div class="empty-state">
<div class="empty-icon"> <div class="empty-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg
<rect x="3" y="5" width="18" height="14" rx="2" stroke="currentColor" stroke-width="1.5"/> width="48"
<circle cx="8.5" cy="8.5" r=".5" fill="currentColor"/> height="48"
<path d="M3 16l5-5 3 3 4-4 4 4" stroke="currentColor" stroke-width="1.5" fill="none"/> viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect
x="3"
y="5"
width="18"
height="14"
rx="2"
stroke="currentColor"
stroke-width="1.5"
/>
<circle cx="8.5" cy="8.5" r=".5" fill="currentColor" />
<path d="M3 16l5-5 3 3 4-4 4 4" stroke="currentColor" stroke-width="1.5" fill="none" />
</svg> </svg>
</div> </div>
<p class="empty-text">{placeholder}</p> <p class="empty-text">{placeholder}</p>

View file

@ -31,7 +31,8 @@
const selectedMedia = Array.isArray(media) ? media[0] : media const selectedMedia = Array.isArray(media) ? media[0] : media
if (selectedMedia) { if (selectedMedia) {
// Set a reasonable default width (max 600px) // Set a reasonable default width (max 600px)
const displayWidth = selectedMedia.width && selectedMedia.width > 600 ? 600 : selectedMedia.width const displayWidth =
selectedMedia.width && selectedMedia.width > 600 ? 600 : selectedMedia.width
editor editor
.chain() .chain()
@ -273,8 +274,12 @@
} }
@keyframes spin { @keyframes spin {
0% { transform: rotate(0deg); } 0% {
100% { transform: rotate(360deg); } transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
} }
:global(.edra-image-placeholder-icon) { :global(.edra-image-placeholder-icon) {

View file

@ -136,7 +136,6 @@
isUploading = false isUploading = false
uploadProgress = 0 uploadProgress = 0
}, 500) }, 500)
} catch (err) { } catch (err) {
isUploading = false isUploading = false
uploadProgress = 0 uploadProgress = 0
@ -276,7 +275,7 @@
alt={value?.altText || value?.filename || 'Uploaded image'} alt={value?.altText || value?.filename || 'Uploaded image'}
containerWidth={100} containerWidth={100}
loading="eager" loading="eager"
aspectRatio={aspectRatio} {aspectRatio}
class="preview-image" class="preview-image"
/> />
@ -288,9 +287,28 @@
</Button> </Button>
<Button variant="overlay" buttonSize="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"> <svg
<polyline points="3,6 5,6 21,6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> slot="icon"
<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"/> 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"
/>
</svg> </svg>
</Button> </Button>
</div> </div>
@ -331,7 +349,7 @@
alt={value?.altText || value?.filename || 'Uploaded image'} alt={value?.altText || value?.filename || 'Uploaded image'}
containerWidth={800} containerWidth={800}
loading="eager" loading="eager"
aspectRatio={aspectRatio} {aspectRatio}
class="preview-image" class="preview-image"
/> />
@ -344,9 +362,28 @@
</Button> </Button>
<Button variant="overlay" buttonSize="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"> <svg
<polyline points="3,6 5,6 21,6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> slot="icon"
<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"/> 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"
/>
</svg> </svg>
Remove Remove
</Button> </Button>
@ -365,7 +402,6 @@
</p> </p>
</div> </div>
{/if} {/if}
{:else} {:else}
<!-- Upload Drop Zone --> <!-- Upload Drop Zone -->
<div <div
@ -412,12 +448,53 @@
{:else} {:else}
<!-- Upload Prompt --> <!-- Upload Prompt -->
<div class="upload-prompt"> <div class="upload-prompt">
<svg class="upload-icon" width="48" height="48" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg
<path d="M14 2H6A2 2 0 0 0 4 4V20A2 2 0 0 0 6 22H18A2 2 0 0 0 20 20V8L14 2Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> class="upload-icon"
<polyline points="14,2 14,8 20,8" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> width="48"
<line x1="16" y1="13" x2="8" y2="13" stroke="currentColor" stroke-width="2" stroke-linecap="round"/> height="48"
<line x1="16" y1="17" x2="8" y2="17" stroke="currentColor" stroke-width="2" stroke-linecap="round"/> viewBox="0 0 24 24"
<polyline points="10,9 9,9 8,9" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M14 2H6A2 2 0 0 0 4 4V20A2 2 0 0 0 6 22H18A2 2 0 0 0 20 20V8L14 2Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<polyline
points="14,2 14,8 20,8"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<line
x1="16"
y1="13"
x2="8"
y2="13"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
<line
x1="16"
y1="17"
x2="8"
y2="17"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
<polyline
points="10,9 9,9 8,9"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg> </svg>
<p class="upload-main-text">{placeholder}</p> <p class="upload-main-text">{placeholder}</p>
<p class="upload-sub-text"> <p class="upload-sub-text">
@ -432,14 +509,10 @@
<!-- Action Buttons --> <!-- Action Buttons -->
{#if !hasValue && !isUploading} {#if !hasValue && !isUploading}
<div class="action-buttons"> <div class="action-buttons">
<Button variant="primary" onclick={handleBrowseClick}> <Button variant="primary" onclick={handleBrowseClick}>Choose File</Button>
Choose File
</Button>
{#if showBrowseLibrary} {#if showBrowseLibrary}
<Button variant="ghost" onclick={handleBrowseLibrary}> <Button variant="ghost" onclick={handleBrowseLibrary}>Browse Library</Button>
Browse Library
</Button>
{/if} {/if}
</div> </div>
{/if} {/if}

View file

@ -13,12 +13,7 @@
onUpdate: (updatedMedia: Media) => void onUpdate: (updatedMedia: Media) => void
} }
let { let { isOpen = $bindable(), media, onClose, onUpdate }: Props = $props()
isOpen = $bindable(),
media,
onClose,
onUpdate
}: Props = $props()
// Form state // Form state
let altText = $state('') let altText = $state('')
@ -29,14 +24,16 @@
let successMessage = $state('') let successMessage = $state('')
// Usage tracking state // Usage tracking state
let usage = $state<Array<{ let usage = $state<
Array<{
contentType: string contentType: string
contentId: number contentId: number
contentTitle: string contentTitle: string
fieldDisplayName: string fieldDisplayName: string
contentUrl?: string contentUrl?: string
createdAt: string createdAt: string
}>>([]) }>
>([])
let loadingUsage = $state(false) let loadingUsage = $state(false)
// Initialize form when media changes // Initialize form when media changes
@ -115,7 +112,6 @@
setTimeout(() => { setTimeout(() => {
handleClose() handleClose()
}, 1500) }, 1500)
} catch (err) { } catch (err) {
error = 'Failed to update media. Please try again.' error = 'Failed to update media. Please try again.'
console.error('Failed to update media:', err) console.error('Failed to update media:', err)
@ -125,7 +121,10 @@
} }
async function handleDelete() { async function handleDelete() {
if (!media || !confirm('Are you sure you want to delete this media file? This action cannot be undone.')) { if (
!media ||
!confirm('Are you sure you want to delete this media file? This action cannot be undone.')
) {
return return
} }
@ -144,7 +143,6 @@
// Close modal and let parent handle the deletion // Close modal and let parent handle the deletion
handleClose() handleClose()
// Note: Parent component should refresh the media list // Note: Parent component should refresh the media list
} catch (err) { } catch (err) {
error = 'Failed to delete media. Please try again.' error = 'Failed to delete media. Please try again.'
console.error('Failed to delete media:', err) console.error('Failed to delete media:', err)
@ -155,12 +153,15 @@
function copyUrl() { function copyUrl() {
if (media?.url) { if (media?.url) {
navigator.clipboard.writeText(media.url).then(() => { navigator.clipboard
.writeText(media.url)
.then(() => {
successMessage = 'URL copied to clipboard!' successMessage = 'URL copied to clipboard!'
setTimeout(() => { setTimeout(() => {
successMessage = '' successMessage = ''
}, 2000) }, 2000)
}).catch(() => { })
.catch(() => {
error = 'Failed to copy URL' error = 'Failed to copy URL'
setTimeout(() => { setTimeout(() => {
error = '' error = ''
@ -187,7 +188,13 @@
</script> </script>
{#if media} {#if media}
<Modal bind:isOpen size="large" closeOnBackdrop={!isSaving} closeOnEscape={!isSaving} on:close={handleClose}> <Modal
bind:isOpen
size="large"
closeOnBackdrop={!isSaving}
closeOnEscape={!isSaving}
on:close={handleClose}
>
<div class="media-details-modal"> <div class="media-details-modal">
<!-- Header --> <!-- Header -->
<div class="modal-header"> <div class="modal-header">
@ -197,8 +204,20 @@
</div> </div>
{#if !isSaving} {#if !isSaving}
<Button variant="ghost" onclick={handleClose} iconOnly aria-label="Close modal"> <Button variant="ghost" onclick={handleClose} iconOnly aria-label="Close modal">
<svg slot="icon" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg
<path d="M6 6L18 18M6 18L18 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" /> slot="icon"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 6L18 18M6 18L18 6"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
</svg> </svg>
</Button> </Button>
{/if} {/if}
@ -213,9 +232,27 @@
<SmartImage {media} alt={media.altText || media.filename} /> <SmartImage {media} alt={media.altText || media.filename} />
{:else} {:else}
<div class="file-placeholder"> <div class="file-placeholder">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg
<path d="M14 2H6A2 2 0 0 0 4 4V20A2 2 0 0 0 6 22H18A2 2 0 0 0 20 20V8L14 2Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> width="64"
<polyline points="14,2 14,8 20,8" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> height="64"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M14 2H6A2 2 0 0 0 4 4V20A2 2 0 0 0 6 22H18A2 2 0 0 0 20 20V8L14 2Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<polyline
points="14,2 14,8 20,8"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg> </svg>
<span class="file-type">{getFileType(media.mimeType)}</span> <span class="file-type">{getFileType(media.mimeType)}</span>
</div> </div>
@ -246,9 +283,7 @@
<span class="label">URL:</span> <span class="label">URL:</span>
<div class="url-section"> <div class="url-section">
<span class="url-text">{media.url}</span> <span class="url-text">{media.url}</span>
<Button variant="ghost" buttonSize="small" onclick={copyUrl}> <Button variant="ghost" buttonSize="small" onclick={copyUrl}>Copy</Button>
Copy
</Button>
</div> </div>
</div> </div>
</div> </div>
@ -291,7 +326,8 @@
<span class="toggle-slider"></span> <span class="toggle-slider"></span>
<div class="toggle-content"> <div class="toggle-content">
<span class="toggle-title">Photography</span> <span class="toggle-title">Photography</span>
<span class="toggle-description">Show this media in the photography experience</span> <span class="toggle-description">Show this media in the photography experience</span
>
</div> </div>
</label> </label>
</div> </div>
@ -311,7 +347,12 @@
<div class="usage-content"> <div class="usage-content">
<div class="usage-header"> <div class="usage-header">
{#if usageItem.contentUrl} {#if usageItem.contentUrl}
<a href={usageItem.contentUrl} class="usage-title" target="_blank" rel="noopener"> <a
href={usageItem.contentUrl}
class="usage-title"
target="_blank"
rel="noopener"
>
{usageItem.contentTitle} {usageItem.contentTitle}
</a> </a>
{:else} {:else}
@ -321,7 +362,9 @@
</div> </div>
<div class="usage-details"> <div class="usage-details">
<span class="usage-field">{usageItem.fieldDisplayName}</span> <span class="usage-field">{usageItem.fieldDisplayName}</span>
<span class="usage-date">Added {new Date(usageItem.createdAt).toLocaleDateString()}</span> <span class="usage-date"
>Added {new Date(usageItem.createdAt).toLocaleDateString()}</span
>
</div> </div>
</div> </div>
</li> </li>
@ -337,12 +380,7 @@
<!-- Footer --> <!-- Footer -->
<div class="modal-footer"> <div class="modal-footer">
<div class="footer-left"> <div class="footer-left">
<Button <Button variant="ghost" onclick={handleDelete} disabled={isSaving} class="delete-button">
variant="ghost"
onclick={handleDelete}
disabled={isSaving}
class="delete-button"
>
Delete Delete
</Button> </Button>
</div> </div>
@ -355,9 +393,7 @@
<span class="success-text">{successMessage}</span> <span class="success-text">{successMessage}</span>
{/if} {/if}
<Button variant="ghost" onclick={handleClose} disabled={isSaving}> <Button variant="ghost" onclick={handleClose} disabled={isSaving}>Cancel</Button>
Cancel
</Button>
<Button variant="primary" onclick={handleSave} disabled={isSaving}> <Button variant="primary" onclick={handleSave} disabled={isSaving}>
{isSaving ? 'Saving...' : 'Save Changes'} {isSaving ? 'Saving...' : 'Save Changes'}
</Button> </Button>
@ -711,8 +747,12 @@
} }
@keyframes spin { @keyframes spin {
0% { transform: rotate(0deg); } 0% {
100% { transform: rotate(360deg); } transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
} }
// Responsive adjustments // Responsive adjustments

View file

@ -75,17 +75,17 @@
: mode === 'single' && value && !Array.isArray(value) : mode === 'single' && value && !Array.isArray(value)
? [value.id] ? [value.id]
: mode === 'multiple' && Array.isArray(value) : mode === 'multiple' && Array.isArray(value)
? value.map(item => item.id) ? value.map((item) => item.id)
: [] : []
) )
const modalTitle = $derived( const modalTitle = $derived(
mode === 'single' ? `Select ${fileType === 'image' ? 'Image' : 'Media'}` : `Select ${fileType === 'image' ? 'Images' : 'Media'}` mode === 'single'
? `Select ${fileType === 'image' ? 'Image' : 'Media'}`
: `Select ${fileType === 'image' ? 'Images' : 'Media'}`
) )
const confirmText = $derived( const confirmText = $derived(mode === 'single' ? 'Select' : 'Select Files')
mode === 'single' ? 'Select' : 'Select Files'
)
</script> </script>
<div class="media-input"> <div class="media-input">
@ -106,10 +106,29 @@
<img src={value.thumbnailUrl} alt={value.filename} /> <img src={value.thumbnailUrl} alt={value.filename} />
{:else} {:else}
<div class="media-placeholder"> <div class="media-placeholder">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg
<rect x="3" y="5" width="18" height="14" rx="2" stroke="currentColor" stroke-width="2"/> width="24"
<circle cx="8.5" cy="8.5" r=".5" fill="currentColor"/> height="24"
<path d="M3 16l5-5 3 3 4-4 4 4" stroke="currentColor" stroke-width="2" fill="none"/> viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect
x="3"
y="5"
width="18"
height="14"
rx="2"
stroke="currentColor"
stroke-width="2"
/>
<circle cx="8.5" cy="8.5" r=".5" fill="currentColor" />
<path
d="M3 16l5-5 3 3 4-4 4 4"
stroke="currentColor"
stroke-width="2"
fill="none"
/>
</svg> </svg>
</div> </div>
{/if} {/if}
@ -133,10 +152,29 @@
<img src={item.thumbnailUrl} alt={item.filename} /> <img src={item.thumbnailUrl} alt={item.filename} />
{:else} {:else}
<div class="media-placeholder"> <div class="media-placeholder">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg
<rect x="3" y="5" width="18" height="14" rx="2" stroke="currentColor" stroke-width="2"/> width="16"
<circle cx="8.5" cy="8.5" r=".5" fill="currentColor"/> height="16"
<path d="M3 16l5-5 3 3 4-4 4 4" stroke="currentColor" stroke-width="2" fill="none"/> viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect
x="3"
y="5"
width="18"
height="14"
rx="2"
stroke="currentColor"
stroke-width="2"
/>
<circle cx="8.5" cy="8.5" r=".5" fill="currentColor" />
<path
d="M3 16l5-5 3 3 4-4 4 4"
stroke="currentColor"
stroke-width="2"
fill="none"
/>
</svg> </svg>
</div> </div>
{/if} {/if}
@ -168,9 +206,7 @@
class:placeholder={!hasValue} class:placeholder={!hasValue}
/> />
<div class="input-actions"> <div class="input-actions">
<Button variant="ghost" onclick={openModal}> <Button variant="ghost" onclick={openModal}>Browse</Button>
Browse
</Button>
{#if hasValue} {#if hasValue}
<Button variant="ghost" onclick={handleClear} aria-label="Clear selection"> <Button variant="ghost" onclick={handleClear} aria-label="Clear selection">
<svg <svg
@ -205,7 +241,7 @@
{fileType} {fileType}
{selectedIds} {selectedIds}
title={modalTitle} title={modalTitle}
confirmText={confirmText} {confirmText}
onselect={handleMediaSelect} onselect={handleMediaSelect}
/> />
</div> </div>

View file

@ -60,7 +60,9 @@
const selectionCount = $derived(selectedMedia.length) const selectionCount = $derived(selectedMedia.length)
const footerText = $derived( const footerText = $derived(
mode === 'single' mode === 'single'
? canConfirm ? '1 item selected' : 'No item selected' ? canConfirm
? '1 item selected'
: 'No item selected'
: `${selectionCount} item${selectionCount !== 1 ? 's' : ''} selected` : `${selectionCount} item${selectionCount !== 1 ? 's' : ''} selected`
) )
</script> </script>
@ -117,14 +119,8 @@
<span class="selection-count">{footerText}</span> <span class="selection-count">{footerText}</span>
</div> </div>
<div class="footer-actions"> <div class="footer-actions">
<Button variant="ghost" onclick={handleCancel} disabled={isLoading}> <Button variant="ghost" onclick={handleCancel} disabled={isLoading}>Cancel</Button>
Cancel <Button variant="primary" onclick={handleConfirm} disabled={!canConfirm || isLoading}>
</Button>
<Button
variant="primary"
onclick={handleConfirm}
disabled={!canConfirm || isLoading}
>
{confirmText} {confirmText}
</Button> </Button>
</div> </div>

View file

@ -12,12 +12,7 @@
loading?: boolean loading?: boolean
} }
let { let { mode, fileType = 'all', selectedIds = [], loading = $bindable(false) }: Props = $props()
mode,
fileType = 'all',
selectedIds = [],
loading = $bindable(false)
}: Props = $props()
const dispatch = createEventDispatcher<{ const dispatch = createEventDispatcher<{
select: Media[] select: Media[]
@ -37,7 +32,7 @@
// Initialize selected media from IDs // Initialize selected media from IDs
$effect(() => { $effect(() => {
if (selectedIds.length > 0 && media.length > 0) { if (selectedIds.length > 0 && media.length > 0) {
selectedMedia = media.filter(item => selectedIds.includes(item.id)) selectedMedia = media.filter((item) => selectedIds.includes(item.id))
dispatch('select', selectedMedia) dispatch('select', selectedMedia)
} }
}) })
@ -112,7 +107,6 @@
currentPage = page currentPage = page
totalPages = data.pagination.totalPages totalPages = data.pagination.totalPages
total = data.pagination.total total = data.pagination.total
} catch (error) { } catch (error) {
console.error('Error loading media:', error) console.error('Error loading media:', error)
} finally { } finally {
@ -125,10 +119,10 @@
selectedMedia = [item] selectedMedia = [item]
dispatch('select', selectedMedia) dispatch('select', selectedMedia)
} else { } else {
const isSelected = selectedMedia.some(m => m.id === item.id) const isSelected = selectedMedia.some((m) => m.id === item.id)
if (isSelected) { if (isSelected) {
selectedMedia = selectedMedia.filter(m => m.id !== item.id) selectedMedia = selectedMedia.filter((m) => m.id !== item.id)
} else { } else {
selectedMedia = [...selectedMedia, item] selectedMedia = [...selectedMedia, item]
} }
@ -161,7 +155,7 @@
} }
function isSelected(item: Media): boolean { function isSelected(item: Media): boolean {
return selectedMedia.some(m => m.id === item.id) return selectedMedia.some((m) => m.id === item.id)
} }
// Computed properties // Computed properties
@ -174,11 +168,7 @@
<!-- Search and Filter Controls --> <!-- Search and Filter Controls -->
<div class="controls"> <div class="controls">
<div class="search-filters"> <div class="search-filters">
<Input <Input type="search" placeholder="Search media files..." bind:value={searchQuery} />
type="search"
placeholder="Search media files..."
bind:value={searchQuery}
/>
<select bind:value={filterType} class="filter-select"> <select bind:value={filterType} class="filter-select">
<option value="all">All Files</option> <option value="all">All Files</option>
@ -216,10 +206,16 @@
</div> </div>
{:else if media.length === 0} {:else if media.length === 0}
<div class="empty-state"> <div class="empty-state">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg
<rect x="3" y="5" width="18" height="14" rx="2" stroke="currentColor" stroke-width="2"/> width="64"
<circle cx="8.5" cy="8.5" r=".5" fill="currentColor"/> height="64"
<path d="M3 16l5-5 3 3 4-4 4 4" stroke="currentColor" stroke-width="2" fill="none"/> viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect x="3" y="5" width="18" height="14" rx="2" stroke="currentColor" stroke-width="2" />
<circle cx="8.5" cy="8.5" r=".5" fill="currentColor" />
<path d="M3 16l5-5 3 3 4-4 4 4" stroke="currentColor" stroke-width="2" fill="none" />
</svg> </svg>
<h3>No media found</h3> <h3>No media found</h3>
<p>Try adjusting your search or upload some files</p> <p>Try adjusting your search or upload some files</p>
@ -237,16 +233,35 @@
<div class="media-thumbnail"> <div class="media-thumbnail">
{#if item.mimeType?.startsWith('image/')} {#if item.mimeType?.startsWith('image/')}
<img <img
src={item.mimeType === 'image/svg+xml' ? item.url : (item.thumbnailUrl || item.url)} src={item.mimeType === 'image/svg+xml' ? item.url : item.thumbnailUrl || item.url}
alt={item.filename} alt={item.filename}
loading="lazy" loading="lazy"
/> />
{:else} {:else}
<div class="media-placeholder"> <div class="media-placeholder">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg
<rect x="3" y="5" width="18" height="14" rx="2" stroke="currentColor" stroke-width="2"/> width="32"
<circle cx="8.5" cy="8.5" r=".5" fill="currentColor"/> height="32"
<path d="M3 16l5-5 3 3 4-4 4 4" stroke="currentColor" stroke-width="2" fill="none"/> viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect
x="3"
y="5"
width="18"
height="14"
rx="2"
stroke="currentColor"
stroke-width="2"
/>
<circle cx="8.5" cy="8.5" r=".5" fill="currentColor" />
<path
d="M3 16l5-5 3 3 4-4 4 4"
stroke="currentColor"
stroke-width="2"
fill="none"
/>
</svg> </svg>
</div> </div>
{/if} {/if}
@ -254,19 +269,27 @@
<!-- Selection Indicator --> <!-- Selection Indicator -->
{#if mode === 'multiple'} {#if mode === 'multiple'}
<div class="selection-checkbox"> <div class="selection-checkbox">
<input <input type="checkbox" checked={isSelected(item)} readonly />
type="checkbox"
checked={isSelected(item)}
readonly
/>
</div> </div>
{/if} {/if}
<!-- Selected Overlay --> <!-- Selected Overlay -->
{#if isSelected(item)} {#if isSelected(item)}
<div class="selected-overlay"> <div class="selected-overlay">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg
<path d="M9 12l2 2 4-4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M9 12l2 2 4-4"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg> </svg>
</div> </div>
{/if} {/if}
@ -280,8 +303,17 @@
<div class="media-indicators"> <div class="media-indicators">
{#if item.isPhotography} {#if item.isPhotography}
<span class="indicator-pill photography" title="Photography"> <span class="indicator-pill photography" title="Photography">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg
<polygon points="12,2 15.09,8.26 22,9 17,14.74 18.18,21.02 12,17.77 5.82,21.02 7,14.74 2,9 8.91,8.26" fill="currentColor"/> width="10"
height="10"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<polygon
points="12,2 15.09,8.26 22,9 17,14.74 18.18,21.02 12,17.77 5.82,21.02 7,14.74 2,9 8.91,8.26"
fill="currentColor"
/>
</svg> </svg>
Photo Photo
</span> </span>
@ -291,9 +323,7 @@
Alt Alt
</span> </span>
{:else} {:else}
<span class="indicator-pill no-alt-text" title="No alt text"> <span class="indicator-pill no-alt-text" title="No alt text"> No Alt </span>
No Alt
</span>
{/if} {/if}
</div> </div>
<div class="media-meta"> <div class="media-meta">
@ -310,12 +340,7 @@
<!-- Load More Button --> <!-- Load More Button -->
{#if hasMore} {#if hasMore}
<div class="load-more-container"> <div class="load-more-container">
<Button <Button variant="ghost" onclick={loadMore} disabled={loading} class="load-more-button">
variant="ghost"
onclick={loadMore}
disabled={loading}
class="load-more-button"
>
{#if loading} {#if loading}
<LoadingSpinner buttonSize="small" /> <LoadingSpinner buttonSize="small" />
Loading... Loading...

View file

@ -258,7 +258,12 @@
<div class="file-list-header"> <div class="file-list-header">
<h3>Files to Upload</h3> <h3>Files to Upload</h3>
<div class="file-actions"> <div class="file-actions">
<Button variant="secondary" buttonSize="small" onclick={clearAll} disabled={isUploading}> <Button
variant="secondary"
buttonSize="small"
onclick={clearAll}
disabled={isUploading}
>
Clear All Clear All
</Button> </Button>
<Button <Button

View file

@ -132,11 +132,7 @@
<div class="popover-content"> <div class="popover-content">
<h3>Post Settings</h3> <h3>Post Settings</h3>
<Input <Input label="Slug" bind:value={slug} placeholder="post-slug" />
label="Slug"
bind:value={slug}
placeholder="post-slug"
/>
{#if postType === 'essay'} {#if postType === 'essay'}
<Input <Input

View file

@ -119,7 +119,12 @@
status, status,
content: editorContent, content: editorContent,
featuredImage: featuredImage.url, featuredImage: featuredImage.url,
tags: tags ? tags.split(',').map(tag => tag.trim()).filter(Boolean) : [], tags: tags
? tags
.split(',')
.map((tag) => tag.trim())
.filter(Boolean)
: [],
excerpt: generateExcerpt(editorContent) excerpt: generateExcerpt(editorContent)
} }
@ -147,7 +152,6 @@
} else { } else {
goto('/admin/posts') goto('/admin/posts')
} }
} catch (err) { } catch (err) {
error = `Failed to ${mode === 'edit' ? 'update' : 'create'} photo post` error = `Failed to ${mode === 'edit' ? 'update' : 'create'} photo post`
console.error(err) console.error(err)
@ -193,13 +197,19 @@
<div class="header-actions"> <div class="header-actions">
{#if !isSaving} {#if !isSaving}
<Button variant="ghost" onclick={() => goto('/admin/posts')}> <Button variant="ghost" onclick={() => goto('/admin/posts')}>Cancel</Button>
Cancel <Button
</Button> variant="secondary"
<Button variant="secondary" onclick={handleDraft} disabled={!featuredImage || !title.trim()}> onclick={handleDraft}
disabled={!featuredImage || !title.trim()}
>
Save Draft Save Draft
</Button> </Button>
<Button variant="primary" onclick={handlePublish} disabled={!featuredImage || !title.trim()}> <Button
variant="primary"
onclick={handlePublish}
disabled={!featuredImage || !title.trim()}
>
{isSaving ? 'Publishing...' : 'Publish'} {isSaving ? 'Publishing...' : 'Publish'}
</Button> </Button>
{/if} {/if}

View file

@ -48,13 +48,17 @@
label: 'Slug', label: 'Slug',
placeholder: 'post-slug' placeholder: 'post-slug'
}, },
...(postType === 'essay' ? [{ ...(postType === 'essay'
? [
{
type: 'textarea' as const, type: 'textarea' as const,
key: 'excerpt', key: 'excerpt',
label: 'Excerpt', label: 'Excerpt',
rows: 3, rows: 3,
placeholder: 'Brief description...' placeholder: 'Brief description...'
}] : []), }
]
: []),
{ {
type: 'tags', type: 'tags',
key: 'tags', key: 'tags',

View file

@ -108,7 +108,6 @@
formData.caseStudyContent = content formData.caseStudyContent = content
} }
async function handleSave() { async function handleSave() {
// Check if we're on the case study tab and should save editor content // Check if we're on the case study tab and should save editor content
if (activeTab === 'case-study' && editorRef) { if (activeTab === 'case-study' && editorRef) {
@ -134,7 +133,6 @@
return return
} }
const payload = { const payload = {
title: formData.title, title: formData.title,
subtitle: formData.subtitle, subtitle: formData.subtitle,
@ -236,7 +234,11 @@
dropdownActions={[ dropdownActions={[
{ label: 'Save as Draft', status: 'draft' }, { label: 'Save as Draft', status: 'draft' },
{ label: 'List Only', status: 'list-only', show: formData.status !== 'list-only' }, { label: 'List Only', status: 'list-only', show: formData.status !== 'list-only' },
{ label: 'Password Protected', status: 'password-protected', show: formData.status !== 'password-protected' } {
label: 'Password Protected',
status: 'password-protected',
show: formData.status !== 'password-protected'
}
]} ]}
/> />
{/if} {/if}
@ -351,8 +353,6 @@
} }
} }
.tab-panels { .tab-panels {
position: relative; position: relative;

View file

@ -85,7 +85,6 @@
document.addEventListener('closeDropdowns', handleCloseDropdowns) document.addEventListener('closeDropdowns', handleCloseDropdowns)
return () => document.removeEventListener('closeDropdowns', handleCloseDropdowns) return () => document.removeEventListener('closeDropdowns', handleCloseDropdowns)
}) })
</script> </script>
<div <div
@ -115,11 +114,7 @@
</div> </div>
<div class="dropdown-container"> <div class="dropdown-container">
<button <button class="action-button" onclick={handleToggleDropdown} aria-label="Project actions">
class="action-button"
onclick={handleToggleDropdown}
aria-label="Project actions"
>
<svg <svg
width="20" width="20"
height="20" height="20"
@ -201,7 +196,6 @@
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.dropdown-container { .dropdown-container {
position: relative; position: relative;
flex-shrink: 0; flex-shrink: 0;
@ -265,5 +259,4 @@
background-color: $grey-90; background-color: $grey-90;
margin: $unit-half 0; margin: $unit-half 0;
} }
</style> </style>

View file

@ -44,25 +44,15 @@
onPublish={handlePublish} onPublish={handlePublish}
onSaveDraft={handleSaveDraft} onSaveDraft={handleSaveDraft}
disabled={isDisabled} disabled={isDisabled}
isLoading={isLoading} {isLoading}
/> />
{:else if status === 'published'} {:else if status === 'published'}
<Button <Button variant="primary" buttonSize="large" onclick={handleSave} disabled={isDisabled}>
variant="primary"
buttonSize="large"
onclick={handleSave}
disabled={isDisabled}
>
{isLoading ? 'Saving...' : 'Save'} {isLoading ? 'Saving...' : 'Save'}
</Button> </Button>
{:else} {:else}
<!-- For other statuses like 'list-only', 'password-protected', etc. --> <!-- For other statuses like 'list-only', 'password-protected', etc. -->
<Button <Button variant="primary" buttonSize="large" onclick={handleSave} disabled={isDisabled}>
variant="primary"
buttonSize="large"
onclick={handleSave}
disabled={isDisabled}
>
{isLoading ? 'Saving...' : 'Save'} {isLoading ? 'Saving...' : 'Save'}
</Button> </Button>
{/if} {/if}

View file

@ -229,7 +229,6 @@
} }
} }
.composer-container { .composer-container {
max-width: 600px; max-width: 600px;
margin: 0 auto; margin: 0 auto;

View file

@ -60,9 +60,7 @@
}) })
const availableActions = $derived( const availableActions = $derived(
dropdownActions.filter(action => dropdownActions.filter((action) => action.show !== false && action.status !== currentStatus)
action.show !== false && action.status !== currentStatus
)
) )
</script> </script>

View file

@ -22,8 +22,7 @@
import { Extension, type Range, type Dispatch } from '@tiptap/core' import { Extension, type Range, type Dispatch } from '@tiptap/core'
import { Decoration, DecorationSet } from '@tiptap/pm/view' import { Decoration, DecorationSet } from '@tiptap/pm/view'
import { Plugin, PluginKey, type EditorState, type Transaction } from '@tiptap/pm/state' import { Plugin, PluginKey } from '@tiptap/pm/state'
import { Node as PMNode } from '@tiptap/pm/model'
declare module '@tiptap/core' { declare module '@tiptap/core' {
interface Commands<ReturnType> { interface Commands<ReturnType> {

View file

@ -13,12 +13,16 @@ declare module '@tiptap/core' {
/** /**
* Insert a gallery * Insert a gallery
*/ */
setGallery: (options: { images: Array<{ id: number; url: string; alt?: string; title?: string }> }) => ReturnType setGallery: (options: {
images: Array<{ id: number; url: string; alt?: string; title?: string }>
}) => ReturnType
} }
} }
} }
export const GalleryExtended = (component: Component<NodeViewProps>): Node<GalleryOptions, unknown> => { export const GalleryExtended = (
component: Component<NodeViewProps>
): Node<GalleryOptions, unknown> => {
return Node.create<GalleryOptions>({ return Node.create<GalleryOptions>({
name: 'gallery', name: 'gallery',
@ -46,15 +50,16 @@ export const GalleryExtended = (component: Component<NodeViewProps>): Node<Galle
}, },
parseHTML() { parseHTML() {
return [ return [{ tag: `div[data-type="${this.name}"]` }]
{ tag: `div[data-type="${this.name}"]` }
]
}, },
renderHTML({ HTMLAttributes }) { renderHTML({ HTMLAttributes }) {
return ['div', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { return [
'div',
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
'data-type': this.name 'data-type': this.name
})] })
]
}, },
group: 'block', group: 'block',
@ -67,7 +72,9 @@ export const GalleryExtended = (component: Component<NodeViewProps>): Node<Galle
addCommands() { addCommands() {
return { return {
setGallery: (options) => ({ commands }) => { setGallery:
(options) =>
({ commands }) => {
return commands.insertContent({ return commands.insertContent({
type: this.name, type: this.name,
attrs: { attrs: {

View file

@ -1,7 +1,5 @@
import { Editor, findParentNode } from '@tiptap/core' import { Editor, findParentNode } from '@tiptap/core'
import { EditorState, Selection, Transaction } from '@tiptap/pm/state'
import { CellSelection, type Rect, TableMap } from '@tiptap/pm/tables' import { CellSelection, type Rect, TableMap } from '@tiptap/pm/tables'
import { Node, ResolvedPos } from '@tiptap/pm/model'
import type { EditorView } from '@tiptap/pm/view' import type { EditorView } from '@tiptap/pm/view'
import Table from './table.js' import Table from './table.js'

View file

@ -26,7 +26,7 @@
function handleMediaSelect(media: Media | Media[]) { function handleMediaSelect(media: Media | Media[]) {
const mediaArray = Array.isArray(media) ? media : [media] const mediaArray = Array.isArray(media) ? media : [media]
const newImages = mediaArray.map(m => ({ const newImages = mediaArray.map((m) => ({
id: m.id, id: m.id,
url: m.url, url: m.url,
alt: m.altText || '', alt: m.altText || '',
@ -40,7 +40,7 @@
// Add to existing images // Add to existing images
const existingImages = node.attrs.images || [] const existingImages = node.attrs.images || []
const currentIds = existingImages.map((img: any) => img.id) const currentIds = existingImages.map((img: any) => img.id)
const uniqueNewImages = newImages.filter(img => !currentIds.includes(img.id)) const uniqueNewImages = newImages.filter((img) => !currentIds.includes(img.id))
updateAttributes({ images: [...existingImages, ...uniqueNewImages] }) updateAttributes({ images: [...existingImages, ...uniqueNewImages] })
} }
@ -87,12 +87,7 @@
<div class={`edra-gallery-grid ${layout === 'masonry' ? 'masonry' : 'grid'}`}> <div class={`edra-gallery-grid ${layout === 'masonry' ? 'masonry' : 'grid'}`}>
{#each images as image} {#each images as image}
<div class="edra-gallery-item"> <div class="edra-gallery-item">
<img <img src={image.url} alt={image.alt} title={image.title} loading="lazy" />
src={image.url}
alt={image.alt}
title={image.title}
loading="lazy"
/>
{#if editor?.isEditable} {#if editor?.isEditable}
<button <button
class="edra-gallery-item-remove" class="edra-gallery-item-remove"
@ -141,18 +136,10 @@
</div> </div>
<div class="edra-gallery-toolbar-section"> <div class="edra-gallery-toolbar-section">
<button <button class="edra-toolbar-button" onclick={handleAddImages} title="Add Images">
class="edra-toolbar-button"
onclick={handleAddImages}
title="Add Images"
>
<Plus /> <Plus />
</button> </button>
<button <button class="edra-toolbar-button" onclick={handleEditGallery} title="Edit Gallery">
class="edra-toolbar-button"
onclick={handleEditGallery}
title="Edit Gallery"
>
<Edit /> <Edit />
</button> </button>
<button <button

View file

@ -27,7 +27,7 @@
function handleMediaSelect(media: Media | Media[]) { function handleMediaSelect(media: Media | Media[]) {
const mediaArray = Array.isArray(media) ? media : [media] const mediaArray = Array.isArray(media) ? media : [media]
if (mediaArray.length > 0) { if (mediaArray.length > 0) {
const galleryImages = mediaArray.map(m => ({ const galleryImages = mediaArray.map((m) => ({
id: m.id, id: m.id,
url: m.url, url: m.url,
alt: m.altText || '', alt: m.altText || '',
@ -225,8 +225,12 @@
} }
@keyframes spin { @keyframes spin {
0% { transform: rotate(0deg); } 0% {
100% { transform: rotate(360deg); } transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
} }
:global(.edra-gallery-placeholder-icon) { :global(.edra-gallery-placeholder-icon) {

View file

@ -28,11 +28,15 @@
function handleMediaSelect(media: Media | Media[]) { function handleMediaSelect(media: Media | Media[]) {
const selectedMedia = Array.isArray(media) ? media[0] : media const selectedMedia = Array.isArray(media) ? media[0] : media
if (selectedMedia) { if (selectedMedia) {
editor.chain().focus().setImage({ editor
.chain()
.focus()
.setImage({
src: selectedMedia.url, src: selectedMedia.url,
alt: selectedMedia.altText || '', alt: selectedMedia.altText || '',
title: selectedMedia.description || '' title: selectedMedia.description || ''
}).run() })
.run()
} }
isMediaLibraryOpen = false isMediaLibraryOpen = false
} }
@ -74,11 +78,15 @@
if (response.ok) { if (response.ok) {
const media = await response.json() const media = await response.json()
editor.chain().focus().setImage({ editor
.chain()
.focus()
.setImage({
src: media.url, src: media.url,
alt: media.altText || '', alt: media.altText || '',
title: media.description || '' title: media.description || ''
}).run() })
.run()
} else { } else {
console.error('Failed to upload image:', response.status) console.error('Failed to upload image:', response.status)
alert('Failed to upload image. Please try again.') alert('Failed to upload image. Please try again.')
@ -219,8 +227,12 @@
} }
@keyframes spin { @keyframes spin {
0% { transform: rotate(0deg); } 0% {
100% { transform: rotate(360deg); } transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
} }
:global(.edra-media-placeholder-icon) { :global(.edra-media-placeholder-icon) {

View file

@ -1,5 +1,4 @@
import type { Content, Editor } from '@tiptap/core' import type { Content, Editor } from '@tiptap/core'
import { Node } from '@tiptap/pm/model'
import { Decoration, DecorationSet } from '@tiptap/pm/view' import { Decoration, DecorationSet } from '@tiptap/pm/view'
import type { EditorState, Transaction } from '@tiptap/pm/state' import type { EditorState, Transaction } from '@tiptap/pm/state'
import type { EditorView } from '@tiptap/pm/view' import type { EditorView } from '@tiptap/pm/view'

View file

@ -1,6 +1,7 @@
import { z } from 'zod' import { z } from 'zod'
export const projectSchema = z.object({ export const projectSchema = z
.object({
title: z.string().min(1, 'Title is required'), title: z.string().min(1, 'Title is required'),
description: z.string().optional(), description: z.string().optional(),
year: z year: z
@ -21,7 +22,8 @@ export const projectSchema = z.object({
.or(z.literal('')), .or(z.literal('')),
status: z.enum(['draft', 'published', 'list-only', 'password-protected']), status: z.enum(['draft', 'published', 'list-only', 'password-protected']),
password: z.string().optional() password: z.string().optional()
}).refine( })
.refine(
(data) => { (data) => {
if (data.status === 'password-protected') { if (data.status === 'password-protected') {
return data.password && data.password.trim().length > 0 return data.password && data.password.trim().length > 0
@ -32,6 +34,6 @@ export const projectSchema = z.object({
message: 'Password is required when status is password-protected', message: 'Password is required when status is password-protected',
path: ['password'] path: ['password']
} }
) )
export type ProjectSchema = z.infer<typeof projectSchema> export type ProjectSchema = z.infer<typeof projectSchema>

View file

@ -216,11 +216,7 @@ export function getResponsiveUrls(publicId: string): Record<string, string> {
} }
// Smart image size selection based on container width // Smart image size selection based on container width
export function getSmartImageUrl( export function getSmartImageUrl(publicId: string, containerWidth: number, retina = true): string {
publicId: string,
containerWidth: number,
retina = true
): string {
// Account for retina displays // Account for retina displays
const targetWidth = retina ? containerWidth * 2 : containerWidth const targetWidth = retina ? containerWidth * 2 : containerWidth

View file

@ -24,7 +24,7 @@ export async function trackMediaUsage(references: MediaUsageReference[]) {
if (references.length === 0) return if (references.length === 0) return
// Use upsert to handle duplicates gracefully // Use upsert to handle duplicates gracefully
const operations = references.map(ref => const operations = references.map((ref) =>
prisma.mediaUsage.upsert({ prisma.mediaUsage.upsert({
where: { where: {
mediaId_contentType_contentId_fieldName: { mediaId_contentType_contentId_fieldName: {
@ -84,7 +84,7 @@ export async function updateMediaUsage(
// Add new usage references // Add new usage references
if (mediaIds.length > 0) { if (mediaIds.length > 0) {
await tx.mediaUsage.createMany({ await tx.mediaUsage.createMany({
data: mediaIds.map(mediaId => ({ data: mediaIds.map((mediaId) => ({
mediaId, mediaId,
contentType, contentType,
contentId, contentId,
@ -170,13 +170,13 @@ export async function getMediaUsage(mediaId: number): Promise<MediaUsageDisplay[
*/ */
function getFieldDisplayName(fieldName: string): string { function getFieldDisplayName(fieldName: string): string {
const displayNames: Record<string, string> = { const displayNames: Record<string, string> = {
'featuredImage': 'Featured Image', featuredImage: 'Featured Image',
'logoUrl': 'Logo', logoUrl: 'Logo',
'gallery': 'Gallery', gallery: 'Gallery',
'content': 'Content', content: 'Content',
'coverPhotoId': 'Cover Photo', coverPhotoId: 'Cover Photo',
'photoId': 'Photo', photoId: 'Photo',
'attachments': 'Attachments' attachments: 'Attachments'
} }
return displayNames[fieldName] || fieldName return displayNames[fieldName] || fieldName
@ -195,8 +195,8 @@ export function extractMediaIds(data: any, fieldName: string): number[] {
// Gallery/attachments are arrays of media objects with id property // Gallery/attachments are arrays of media objects with id property
if (Array.isArray(value)) { if (Array.isArray(value)) {
return value return value
.map(item => typeof item === 'object' ? item.id : parseInt(item)) .map((item) => (typeof item === 'object' ? item.id : parseInt(item)))
.filter(id => !isNaN(id)) .filter((id) => !isNaN(id))
} }
return [] return []

View file

@ -59,7 +59,9 @@ async function fetchRecentPSNGames(fetch: typeof window.fetch): Promise<Serializ
async function fetchProjects( async function fetchProjects(
fetch: typeof window.fetch fetch: typeof window.fetch
): Promise<{ projects: Project[]; pagination: any }> { ): Promise<{ projects: Project[]; pagination: any }> {
const response = await fetch('/api/projects?projectType=work&includeListOnly=true&includePasswordProtected=true') const response = await fetch(
'/api/projects?projectType=work&includeListOnly=true&includePasswordProtected=true'
)
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to fetch projects: ${response.status}`) throw new Error(`Failed to fetch projects: ${response.status}`)
} }

View file

@ -238,7 +238,8 @@
{#if photographyFilter === 'all'} {#if photographyFilter === 'all'}
No albums found. Create your first album! No albums found. Create your first album!
{:else} {:else}
No albums found matching the current filters. Try adjusting your filters or create a new album. No albums found matching the current filters. Try adjusting your filters or create a new
album.
{/if} {/if}
</p> </p>
</div> </div>

View file

@ -577,7 +577,7 @@
triggerElement={metadataButtonElement} triggerElement={metadataButtonElement}
onUpdate={handleMetadataUpdate} onUpdate={handleMetadataUpdate}
onDelete={handleMetadataDelete} onDelete={handleMetadataDelete}
onClose={() => isMetadataOpen = false} onClose={() => (isMetadataOpen = false)}
/> />
{/if} {/if}
</div> </div>

View file

@ -258,7 +258,7 @@
triggerElement={metadataButtonElement} triggerElement={metadataButtonElement}
onUpdate={handleMetadataUpdate} onUpdate={handleMetadataUpdate}
onDelete={() => {}} onDelete={() => {}}
onClose={() => isMetadataOpen = false} onClose={() => (isMetadataOpen = false)}
/> />
{/if} {/if}
</div> </div>

View file

@ -30,22 +30,42 @@
<div class="button-group"> <div class="button-group">
<Button buttonSize="small" iconOnly> <Button buttonSize="small" iconOnly>
<svg slot="icon" width="16" height="16" viewBox="0 0 16 16" fill="none"> <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"/> <path
d="M8 4v8m4-4H4"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
/>
</svg> </svg>
</Button> </Button>
<Button buttonSize="medium" iconOnly> <Button buttonSize="medium" iconOnly>
<svg slot="icon" width="18" height="18" viewBox="0 0 18 18" fill="none"> <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"/> <path
d="M9 5v8m4-4H5"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
/>
</svg> </svg>
</Button> </Button>
<Button buttonSize="large" iconOnly> <Button buttonSize="large" iconOnly>
<svg slot="icon" width="20" height="20" viewBox="0 0 20 20" fill="none"> <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"/> <path
d="M10 6v8m4-4H6"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
/>
</svg> </svg>
</Button> </Button>
<Button buttonSize="icon" iconOnly variant="ghost"> <Button buttonSize="icon" iconOnly variant="ghost">
<svg slot="icon" width="18" height="18" viewBox="0 0 18 18" fill="none"> <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"/> <path
d="M6 6l6 6m0-6l-6 6"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
/>
</svg> </svg>
</Button> </Button>
</div> </div>
@ -56,14 +76,25 @@
<div class="button-group"> <div class="button-group">
<Button> <Button>
<svg slot="icon" width="16" height="16" viewBox="0 0 16 16" fill="none"> <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"/> <path
d="M8 4v8m4-4H4"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
/>
</svg> </svg>
Add Item Add Item
</Button> </Button>
<Button iconPosition="right" variant="secondary"> <Button iconPosition="right" variant="secondary">
Next Next
<svg slot="icon" width="16" height="16" viewBox="0 0 16 16" fill="none"> <svg slot="icon" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M6 4l4 4-4 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> <path
d="M6 4l4 4-4 4"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg> </svg>
</Button> </Button>
</div> </div>

View file

@ -130,11 +130,7 @@
<p>Multiple image management with drag-and-drop reordering.</p> <p>Multiple image management with drag-and-drop reordering.</p>
<div class="form-column"> <div class="form-column">
<GalleryManager <GalleryManager label="Image Gallery" bind:value={galleryImages} showFileInfo={false} />
label="Image Gallery"
bind:value={galleryImages}
showFileInfo={false}
/>
<GalleryManager <GalleryManager
label="Project Gallery (Max 6 images)" label="Project Gallery (Max 6 images)"
@ -149,12 +145,8 @@
<section class="test-section"> <section class="test-section">
<h2>Form Actions</h2> <h2>Form Actions</h2>
<div class="actions-grid"> <div class="actions-grid">
<Button variant="primary" onclick={logAllValues}> <Button variant="primary" onclick={logAllValues}>Log All Values</Button>
Log All Values <Button variant="ghost" onclick={clearAllValues}>Clear All</Button>
</Button>
<Button variant="ghost" onclick={clearAllValues}>
Clear All
</Button>
</div> </div>
</section> </section>
@ -169,7 +161,11 @@
<div class="value-item"> <div class="value-item">
<h4>Multiple Media ({multipleMedia.length}):</h4> <h4>Multiple Media ({multipleMedia.length}):</h4>
<pre>{JSON.stringify(multipleMedia.map(m => m.filename), null, 2)}</pre> <pre>{JSON.stringify(
multipleMedia.map((m) => m.filename),
null,
2
)}</pre>
</div> </div>
<div class="value-item"> <div class="value-item">
@ -179,12 +175,20 @@
<div class="value-item"> <div class="value-item">
<h4>Gallery Images ({galleryImages.length}):</h4> <h4>Gallery Images ({galleryImages.length}):</h4>
<pre>{JSON.stringify(galleryImages.map(m => m.filename), null, 2)}</pre> <pre>{JSON.stringify(
galleryImages.map((m) => m.filename),
null,
2
)}</pre>
</div> </div>
<div class="value-item"> <div class="value-item">
<h4>Project Gallery ({projectGallery.length}):</h4> <h4>Project Gallery ({projectGallery.length}):</h4>
<pre>{JSON.stringify(projectGallery.map(m => m.filename), null, 2)}</pre> <pre>{JSON.stringify(
projectGallery.map((m) => m.filename),
null,
2
)}</pre>
</div> </div>
</div> </div>
</section> </section>

View file

@ -39,7 +39,6 @@
<AdminPage title="ImageUploader Test" subtitle="Test the new direct upload functionality"> <AdminPage title="ImageUploader Test" subtitle="Test the new direct upload functionality">
<div class="test-container"> <div class="test-container">
<!-- Basic Image Upload --> <!-- Basic Image Upload -->
<section class="test-section"> <section class="test-section">
<h2>Basic Image Upload</h2> <h2>Basic Image Upload</h2>
@ -95,9 +94,7 @@
<button type="button" class="btn btn-primary" onclick={logAllValues}> <button type="button" class="btn btn-primary" onclick={logAllValues}>
Log All Values Log All Values
</button> </button>
<button type="button" class="btn btn-ghost" onclick={clearAll}> <button type="button" class="btn btn-ghost" onclick={clearAll}> Clear All </button>
Clear All
</button>
</div> </div>
</section> </section>
@ -107,36 +104,53 @@
<div class="values-display"> <div class="values-display">
<div class="value-item"> <div class="value-item">
<h4>Single Image:</h4> <h4>Single Image:</h4>
<pre>{JSON.stringify(singleImage ? { <pre>{JSON.stringify(
singleImage
? {
id: singleImage.id, id: singleImage.id,
filename: singleImage.filename, filename: singleImage.filename,
altText: singleImage.altText, altText: singleImage.altText,
description: singleImage.description description: singleImage.description
} : null, null, 2)}</pre> }
: null,
null,
2
)}</pre>
</div> </div>
<div class="value-item"> <div class="value-item">
<h4>Logo Image:</h4> <h4>Logo Image:</h4>
<pre>{JSON.stringify(logoImage ? { <pre>{JSON.stringify(
logoImage
? {
id: logoImage.id, id: logoImage.id,
filename: logoImage.filename, filename: logoImage.filename,
altText: logoImage.altText, altText: logoImage.altText,
description: logoImage.description description: logoImage.description
} : null, null, 2)}</pre> }
: null,
null,
2
)}</pre>
</div> </div>
<div class="value-item"> <div class="value-item">
<h4>Banner Image:</h4> <h4>Banner Image:</h4>
<pre>{JSON.stringify(bannerImage ? { <pre>{JSON.stringify(
bannerImage
? {
id: bannerImage.id, id: bannerImage.id,
filename: bannerImage.filename, filename: bannerImage.filename,
altText: bannerImage.altText, altText: bannerImage.altText,
description: bannerImage.description description: bannerImage.description
} : null, null, 2)}</pre> }
: null,
null,
2
)}</pre>
</div> </div>
</div> </div>
</section> </section>
</div> </div>
</AdminPage> </AdminPage>

View file

@ -65,8 +65,8 @@
prefixIcon prefixIcon
> >
<svg slot="prefix" width="16" height="16" viewBox="0 0 16 16" fill="none"> <svg slot="prefix" width="16" height="16" viewBox="0 0 16 16" fill="none">
<circle cx="7" cy="7" r="5" stroke="currentColor" stroke-width="1.5"/> <circle cx="7" cy="7" r="5" stroke="currentColor" stroke-width="1.5" />
<path d="M11 11l3 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/> <path d="M11 11l3 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg> </svg>
</Input> </Input>
@ -79,11 +79,7 @@
step={5} step={5}
/> />
<Input <Input type="color" label="Color Input" bind:value={colorValue} />
type="color"
label="Color Input"
bind:value={colorValue}
/>
</div> </div>
</section> </section>
@ -102,23 +98,11 @@
<section> <section>
<h2>Input Sizes</h2> <h2>Input Sizes</h2>
<div class="input-group"> <div class="input-group">
<Input <Input buttonSize="small" label="Small Input" placeholder="Small size" />
buttonSize="small"
label="Small Input"
placeholder="Small size"
/>
<Input <Input buttonSize="medium" label="Medium Input" placeholder="Medium size (default)" />
buttonSize="medium"
label="Medium Input"
placeholder="Medium size (default)"
/>
<Input <Input buttonSize="large" label="Large Input" placeholder="Large size" />
buttonSize="large"
label="Large Input"
placeholder="Large size"
/>
</div> </div>
</section> </section>
@ -129,46 +113,49 @@
label="Input with Error" label="Input with Error"
placeholder="Try typing something" placeholder="Try typing something"
bind:value={withErrorValue} bind:value={withErrorValue}
error={withErrorValue.length > 0 && withErrorValue.length < 3 ? "Too short! Minimum 3 characters" : ""} error={withErrorValue.length > 0 && withErrorValue.length < 3
? 'Too short! Minimum 3 characters'
: ''}
/> />
<Input <Input label="Disabled Input" bind:value={disabledValue} disabled />
label="Disabled Input"
bind:value={disabledValue}
disabled
/>
<Input <Input label="Readonly Input" bind:value={readonlyValue} readonly />
label="Readonly Input"
bind:value={readonlyValue}
readonly
/>
</div> </div>
</section> </section>
<section> <section>
<h2>Input with Icons</h2> <h2>Input with Icons</h2>
<div class="input-group"> <div class="input-group">
<Input <Input label="With Prefix Icon" placeholder="Username" prefixIcon>
label="With Prefix Icon"
placeholder="Username"
prefixIcon
>
<svg slot="prefix" width="16" height="16" viewBox="0 0 16 16" fill="none"> <svg slot="prefix" width="16" height="16" viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="3" stroke="currentColor" stroke-width="1.5"/> <circle cx="8" cy="8" r="3" stroke="currentColor" stroke-width="1.5" />
<path d="M4 14c0-2.21 1.79-4 4-4s4 1.79 4 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/> <path
d="M4 14c0-2.21 1.79-4 4-4s4 1.79 4 4"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
/>
</svg> </svg>
</Input> </Input>
<Input <Input label="With Suffix Icon" placeholder="Email" type="email" suffixIcon>
label="With Suffix Icon"
placeholder="Email"
type="email"
suffixIcon
>
<svg slot="suffix" width="16" height="16" viewBox="0 0 16 16" fill="none"> <svg slot="suffix" width="16" height="16" viewBox="0 0 16 16" fill="none">
<rect x="2" y="4" width="12" height="8" rx="1" stroke="currentColor" stroke-width="1.5"/> <rect
<path d="M2 5l6 3 6-3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/> x="2"
y="4"
width="12"
height="8"
rx="1"
stroke="currentColor"
stroke-width="1.5"
/>
<path
d="M2 5l6 3 6-3"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
/>
</svg> </svg>
</Input> </Input>
</div> </div>
@ -198,11 +185,7 @@
<section> <section>
<h2>Form Example</h2> <h2>Form Example</h2>
<form class="demo-form" on:submit|preventDefault> <form class="demo-form" on:submit|preventDefault>
<Input <Input label="Project Name" placeholder="My Awesome Project" required />
label="Project Name"
placeholder="My Awesome Project"
required
/>
<Input <Input
type="url" type="url"

View file

@ -42,9 +42,7 @@
<h2>Single Selection Mode</h2> <h2>Single Selection Mode</h2>
<p>Test selecting a single media item.</p> <p>Test selecting a single media item.</p>
<Button variant="primary" onclick={openSingleModal}> <Button variant="primary" onclick={openSingleModal}>Open Single Selection Modal</Button>
Open Single Selection Modal
</Button>
{#if selectedSingleMedia} {#if selectedSingleMedia}
<div class="selected-media"> <div class="selected-media">
@ -58,7 +56,10 @@
<p><strong>Size:</strong> {formatFileSize(selectedSingleMedia.size)}</p> <p><strong>Size:</strong> {formatFileSize(selectedSingleMedia.size)}</p>
<p><strong>Type:</strong> {selectedSingleMedia.mimeType}</p> <p><strong>Type:</strong> {selectedSingleMedia.mimeType}</p>
{#if selectedSingleMedia.width && selectedSingleMedia.height} {#if selectedSingleMedia.width && selectedSingleMedia.height}
<p><strong>Dimensions:</strong> {selectedSingleMedia.width}×{selectedSingleMedia.height}</p> <p>
<strong>Dimensions:</strong>
{selectedSingleMedia.width}×{selectedSingleMedia.height}
</p>
{/if} {/if}
</div> </div>
</div> </div>
@ -70,9 +71,7 @@
<h2>Multiple Selection Mode</h2> <h2>Multiple Selection Mode</h2>
<p>Test selecting multiple media items.</p> <p>Test selecting multiple media items.</p>
<Button variant="primary" onclick={openMultipleModal}> <Button variant="primary" onclick={openMultipleModal}>Open Multiple Selection Modal</Button>
Open Multiple Selection Modal
</Button>
{#if selectedMultipleMedia.length > 0} {#if selectedMultipleMedia.length > 0}
<div class="selected-media"> <div class="selected-media">
@ -98,10 +97,13 @@
<h2>Image Only Selection</h2> <h2>Image Only Selection</h2>
<p>Test selecting only image files.</p> <p>Test selecting only image files.</p>
<Button variant="secondary" onclick={() => { <Button
variant="secondary"
onclick={() => {
showSingleModal = true showSingleModal = true
// This will be passed to the modal for image-only filtering // This will be passed to the modal for image-only filtering
}}> }}
>
Open Image Selection Modal Open Image Selection Modal
</Button> </Button>
</section> </section>

View file

@ -47,10 +47,13 @@
function addFiles(newFiles: File[]) { function addFiles(newFiles: File[]) {
// Filter for image files // Filter for image files
const imageFiles = newFiles.filter(file => file.type.startsWith('image/')) const imageFiles = newFiles.filter((file) => file.type.startsWith('image/'))
if (imageFiles.length !== newFiles.length) { if (imageFiles.length !== newFiles.length) {
uploadErrors = [...uploadErrors, `${newFiles.length - imageFiles.length} non-image files were skipped`] uploadErrors = [
...uploadErrors,
`${newFiles.length - imageFiles.length} non-image files were skipped`
]
} }
files = [...files, ...imageFiles] files = [...files, ...imageFiles]
@ -98,7 +101,7 @@
const response = await fetch('/api/media/upload', { const response = await fetch('/api/media/upload', {
method: 'POST', method: 'POST',
headers: { headers: {
'Authorization': `Basic ${auth}` Authorization: `Basic ${auth}`
}, },
body: formData body: formData
}) })
@ -156,12 +159,54 @@
<div class="drop-zone-content"> <div class="drop-zone-content">
{#if files.length === 0} {#if files.length === 0}
<div class="upload-icon"> <div class="upload-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg
<path d="M14 2H6C4.9 2 4 2.9 4 4V20C4 21.1 4.9 22 6 22H18C19.1 22 20 21.1 20 20V8L14 2Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> width="48"
<polyline points="14,2 14,8 20,8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> height="48"
<line x1="16" y1="13" x2="8" y2="13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> viewBox="0 0 24 24"
<line x1="16" y1="17" x2="8" y2="17" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> fill="none"
<polyline points="10,9 9,9 8,9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> xmlns="http://www.w3.org/2000/svg"
>
<path
d="M14 2H6C4.9 2 4 2.9 4 4V20C4 21.1 4.9 22 6 22H18C19.1 22 20 21.1 20 20V8L14 2Z"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<polyline
points="14,2 14,8 20,8"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<line
x1="16"
y1="13"
x2="8"
y2="13"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<line
x1="16"
y1="17"
x2="8"
y2="17"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<polyline
points="10,9 9,9 8,9"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg> </svg>
</div> </div>
<h3>Drop images here</h3> <h3>Drop images here</h3>
@ -200,7 +245,12 @@
<div class="file-list-header"> <div class="file-list-header">
<h3>Files to Upload</h3> <h3>Files to Upload</h3>
<div class="file-actions"> <div class="file-actions">
<Button variant="secondary" buttonSize="small" onclick={clearAll} disabled={isUploading}> <Button
variant="secondary"
buttonSize="small"
onclick={clearAll}
disabled={isUploading}
>
Clear All Clear All
</Button> </Button>
<Button <Button
@ -248,7 +298,14 @@
onclick={() => removeFile(index)} onclick={() => removeFile(index)}
title="Remove file" title="Remove file"
> >
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="18" y1="6" x2="6" y2="18"></line> <line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line> <line x1="6" y1="6" x2="18" y2="18"></line>
</svg> </svg>
@ -267,7 +324,7 @@
<div class="success-message"> <div class="success-message">
✅ Successfully uploaded {successCount} file{successCount !== 1 ? 's' : ''} ✅ Successfully uploaded {successCount} file{successCount !== 1 ? 's' : ''}
{#if successCount === files.length && uploadErrors.length === 0} {#if successCount === files.length && uploadErrors.length === 0}
<br><small>Redirecting to media library...</small> <br /><small>Redirecting to media library...</small>
{/if} {/if}
</div> </div>
{/if} {/if}

View file

@ -171,7 +171,6 @@
} }
} }
function handleMetadataPopover(event: MouseEvent) { function handleMetadataPopover(event: MouseEvent) {
const target = event.target as Node const target = event.target as Node
// Don't close if clicking inside the metadata button or anywhere in a metadata popover // Don't close if clicking inside the metadata button or anywhere in a metadata popover
@ -184,7 +183,6 @@
showMetadata = false showMetadata = false
} }
$effect(() => { $effect(() => {
if (showMetadata) { if (showMetadata) {
document.addEventListener('click', handleMetadataPopover) document.addEventListener('click', handleMetadataPopover)
@ -240,7 +238,7 @@
onAddTag={addTag} onAddTag={addTag}
onRemoveTag={removeTag} onRemoveTag={removeTag}
onDelete={openDeleteConfirmation} onDelete={openDeleteConfirmation}
onClose={() => showMetadata = false} onClose={() => (showMetadata = false)}
/> />
{/if} {/if}
</div> </div>
@ -373,7 +371,6 @@
} }
} }
.btn { .btn {
padding: $unit-2x $unit-3x; padding: $unit-2x $unit-3x;
border: none; border: none;

View file

@ -114,8 +114,6 @@
} }
} }
// Mock post object for metadata popover // Mock post object for metadata popover
const mockPost = $derived({ const mockPost = $derived({
id: null, id: null,
@ -171,7 +169,7 @@
onAddTag={addTag} onAddTag={addTag}
onRemoveTag={removeTag} onRemoveTag={removeTag}
onDelete={() => {}} onDelete={() => {}}
onClose={() => showMetadata = false} onClose={() => (showMetadata = false)}
/> />
{/if} {/if}
</div> </div>
@ -252,7 +250,6 @@
} }
} }
.btn { .btn {
padding: $unit-2x $unit-3x; padding: $unit-2x $unit-3x;
border: none; border: none;

View file

@ -233,7 +233,8 @@
{#if selectedStatusFilter === 'all' && selectedTypeFilter === 'all'} {#if selectedStatusFilter === 'all' && selectedTypeFilter === 'all'}
No projects found. Create your first project! No projects found. Create your first project!
{:else} {:else}
No projects found matching the current filters. Try adjusting your filters or create a new project. No projects found matching the current filters. Try adjusting your filters or create a
new project.
{/if} {/if}
</p> </p>
</div> </div>
@ -263,8 +264,6 @@
/> />
<style lang="scss"> <style lang="scss">
.error { .error {
text-align: center; text-align: center;
padding: $unit-6x; padding: $unit-6x;

View file

@ -1,6 +1,11 @@
import type { RequestHandler } from './$types' import type { RequestHandler } from './$types'
import { prisma } from '$lib/server/database' import { prisma } from '$lib/server/database'
import { jsonResponse, errorResponse, checkAdminAuth, parseRequestBody } from '$lib/server/api-utils' import {
jsonResponse,
errorResponse,
checkAdminAuth,
parseRequestBody
} from '$lib/server/api-utils'
import { logger } from '$lib/server/logger' import { logger } from '$lib/server/logger'
// GET /api/albums/[id] - Get a single album // GET /api/albums/[id] - Get a single album
@ -41,16 +46,16 @@ export const GET: RequestHandler = async (event) => {
// Create a map of media by mediaId for efficient lookup // Create a map of media by mediaId for efficient lookup
const mediaMap = new Map() const mediaMap = new Map()
mediaUsages.forEach(usage => { mediaUsages.forEach((usage) => {
if (usage.media) { if (usage.media) {
mediaMap.set(usage.mediaId, usage.media) mediaMap.set(usage.mediaId, usage.media)
} }
}) })
// Enrich photos with media information using proper media usage tracking // Enrich photos with media information using proper media usage tracking
const photosWithMedia = album.photos.map(photo => { const photosWithMedia = album.photos.map((photo) => {
// Find the corresponding media usage record for this photo // Find the corresponding media usage record for this photo
const usage = mediaUsages.find(u => u.media && u.media.filename === photo.filename) const usage = mediaUsages.find((u) => u.media && u.media.filename === photo.filename)
const media = usage?.media const media = usage?.media
return { return {

View file

@ -1,6 +1,11 @@
import type { RequestHandler } from './$types' import type { RequestHandler } from './$types'
import { prisma } from '$lib/server/database' import { prisma } from '$lib/server/database'
import { jsonResponse, errorResponse, checkAdminAuth, parseRequestBody } from '$lib/server/api-utils' import {
jsonResponse,
errorResponse,
checkAdminAuth,
parseRequestBody
} from '$lib/server/api-utils'
import { logger } from '$lib/server/logger' import { logger } from '$lib/server/logger'
// POST /api/albums/[id]/photos - Add a photo to an album // POST /api/albums/[id]/photos - Add a photo to an album

View file

@ -1,7 +1,12 @@
import type { RequestHandler } from './$types' import type { RequestHandler } from './$types'
import { prisma } from '$lib/server/database' import { prisma } from '$lib/server/database'
import { deleteFile, extractPublicId } from '$lib/server/cloudinary' import { deleteFile, extractPublicId } from '$lib/server/cloudinary'
import { jsonResponse, errorResponse, checkAdminAuth, parseRequestBody } from '$lib/server/api-utils' import {
jsonResponse,
errorResponse,
checkAdminAuth,
parseRequestBody
} from '$lib/server/api-utils'
import { logger } from '$lib/server/logger' import { logger } from '$lib/server/logger'
// GET /api/media/[id] - Get a single media item // GET /api/media/[id] - Get a single media item

View file

@ -57,7 +57,6 @@ export const PATCH: RequestHandler = async (event) => {
description: updatedMedia.description, description: updatedMedia.description,
updatedAt: updatedMedia.updatedAt updatedAt: updatedMedia.updatedAt
}) })
} catch (error) { } catch (error) {
logger.error('Media metadata update error', error as Error) logger.error('Media metadata update error', error as Error)
return errorResponse('Failed to update media metadata', 500) return errorResponse('Failed to update media metadata', 500)

View file

@ -2,7 +2,12 @@ import type { RequestHandler } from './$types'
import { prisma } from '$lib/server/database' import { prisma } from '$lib/server/database'
import { jsonResponse, errorResponse, checkAdminAuth } from '$lib/server/api-utils' import { jsonResponse, errorResponse, checkAdminAuth } from '$lib/server/api-utils'
import { logger } from '$lib/server/logger' import { logger } from '$lib/server/logger'
import { trackMediaUsage, extractMediaIds, removeMediaUsage, type MediaUsageReference } from '$lib/server/media-usage.js' import {
trackMediaUsage,
extractMediaIds,
removeMediaUsage,
type MediaUsageReference
} from '$lib/server/media-usage.js'
// POST /api/media/backfill-usage - Backfill media usage tracking for all content // POST /api/media/backfill-usage - Backfill media usage tracking for all content
export const POST: RequestHandler = async (event) => { export const POST: RequestHandler = async (event) => {
@ -32,7 +37,7 @@ export const POST: RequestHandler = async (event) => {
for (const project of projects) { for (const project of projects) {
// Track featured image // Track featured image
const featuredImageIds = extractMediaIds(project, 'featuredImage') const featuredImageIds = extractMediaIds(project, 'featuredImage')
featuredImageIds.forEach(mediaId => { featuredImageIds.forEach((mediaId) => {
usageReferences.push({ usageReferences.push({
mediaId, mediaId,
contentType: 'project', contentType: 'project',
@ -43,7 +48,7 @@ export const POST: RequestHandler = async (event) => {
// Track logo // Track logo
const logoIds = extractMediaIds(project, 'logoUrl') const logoIds = extractMediaIds(project, 'logoUrl')
logoIds.forEach(mediaId => { logoIds.forEach((mediaId) => {
usageReferences.push({ usageReferences.push({
mediaId, mediaId,
contentType: 'project', contentType: 'project',
@ -54,7 +59,7 @@ export const POST: RequestHandler = async (event) => {
// Track gallery images // Track gallery images
const galleryIds = extractMediaIds(project, 'gallery') const galleryIds = extractMediaIds(project, 'gallery')
galleryIds.forEach(mediaId => { galleryIds.forEach((mediaId) => {
usageReferences.push({ usageReferences.push({
mediaId, mediaId,
contentType: 'project', contentType: 'project',
@ -65,7 +70,7 @@ export const POST: RequestHandler = async (event) => {
// Track media in case study content // Track media in case study content
const contentIds = extractMediaIds(project, 'caseStudyContent') const contentIds = extractMediaIds(project, 'caseStudyContent')
contentIds.forEach(mediaId => { contentIds.forEach((mediaId) => {
usageReferences.push({ usageReferences.push({
mediaId, mediaId,
contentType: 'project', contentType: 'project',
@ -88,7 +93,7 @@ export const POST: RequestHandler = async (event) => {
for (const post of posts) { for (const post of posts) {
// Track featured image // Track featured image
const featuredImageIds = extractMediaIds(post, 'featuredImage') const featuredImageIds = extractMediaIds(post, 'featuredImage')
featuredImageIds.forEach(mediaId => { featuredImageIds.forEach((mediaId) => {
usageReferences.push({ usageReferences.push({
mediaId, mediaId,
contentType: 'post', contentType: 'post',
@ -99,7 +104,7 @@ export const POST: RequestHandler = async (event) => {
// Track attachments // Track attachments
const attachmentIds = extractMediaIds(post, 'attachments') const attachmentIds = extractMediaIds(post, 'attachments')
attachmentIds.forEach(mediaId => { attachmentIds.forEach((mediaId) => {
usageReferences.push({ usageReferences.push({
mediaId, mediaId,
contentType: 'post', contentType: 'post',
@ -110,7 +115,7 @@ export const POST: RequestHandler = async (event) => {
// Track media in post content // Track media in post content
const contentIds = extractMediaIds(post, 'content') const contentIds = extractMediaIds(post, 'content')
contentIds.forEach(mediaId => { contentIds.forEach((mediaId) => {
usageReferences.push({ usageReferences.push({
mediaId, mediaId,
contentType: 'post', contentType: 'post',

View file

@ -1,6 +1,11 @@
import type { RequestHandler } from './$types' import type { RequestHandler } from './$types'
import { prisma } from '$lib/server/database' import { prisma } from '$lib/server/database'
import { jsonResponse, errorResponse, checkAdminAuth, parseRequestBody } from '$lib/server/api-utils' import {
jsonResponse,
errorResponse,
checkAdminAuth,
parseRequestBody
} from '$lib/server/api-utils'
import { logger } from '$lib/server/logger' import { logger } from '$lib/server/logger'
import { removeMediaUsage, extractMediaIds } from '$lib/server/media-usage.js' import { removeMediaUsage, extractMediaIds } from '$lib/server/media-usage.js'
@ -17,7 +22,7 @@ export const DELETE: RequestHandler = async (event) => {
return errorResponse('Invalid request body. Expected array of media IDs.', 400) return errorResponse('Invalid request body. Expected array of media IDs.', 400)
} }
const mediaIds = body.mediaIds.filter(id => typeof id === 'number' && !isNaN(id)) const mediaIds = body.mediaIds.filter((id) => typeof id === 'number' && !isNaN(id))
if (mediaIds.length === 0) { if (mediaIds.length === 0) {
return errorResponse('No valid media IDs provided', 400) return errorResponse('No valid media IDs provided', 400)
} }
@ -50,16 +55,15 @@ export const DELETE: RequestHandler = async (event) => {
logger.info('Bulk media deletion completed', { logger.info('Bulk media deletion completed', {
deletedCount: deleteResult.count, deletedCount: deleteResult.count,
mediaIds, mediaIds,
filenames: mediaRecords.map(m => m.filename) filenames: mediaRecords.map((m) => m.filename)
}) })
return jsonResponse({ return jsonResponse({
success: true, success: true,
message: `Successfully deleted ${deleteResult.count} media file${deleteResult.count > 1 ? 's' : ''}`, message: `Successfully deleted ${deleteResult.count} media file${deleteResult.count > 1 ? 's' : ''}`,
deletedCount: deleteResult.count, deletedCount: deleteResult.count,
deletedFiles: mediaRecords.map(m => ({ id: m.id, filename: m.filename })) deletedFiles: mediaRecords.map((m) => ({ id: m.id, filename: m.filename }))
}) })
} catch (error) { } catch (error) {
logger.error('Failed to bulk delete media files', error as Error) logger.error('Failed to bulk delete media files', error as Error)
return errorResponse('Failed to delete media files', 500) return errorResponse('Failed to delete media files', 500)
@ -74,7 +78,7 @@ async function cleanupMediaReferences(mediaIds: number[]) {
where: { id: { in: mediaIds } }, where: { id: { in: mediaIds } },
select: { url: true } select: { url: true }
}) })
const urlsToRemove = mediaUrls.map(m => m.url) const urlsToRemove = mediaUrls.map((m) => m.url)
// Clean up projects // Clean up projects
const projects = await prisma.project.findMany({ const projects = await prisma.project.findMany({
@ -195,7 +199,7 @@ function cleanContentFromMedia(content: any, mediaIds: number[], urlsToRemove: s
// Remove image nodes that reference deleted media // Remove image nodes that reference deleted media
if (node.type === 'image' && node.attrs?.src) { if (node.type === 'image' && node.attrs?.src) {
const shouldRemove = urlsToRemove.some(url => node.attrs.src.includes(url)) const shouldRemove = urlsToRemove.some((url) => node.attrs.src.includes(url))
if (shouldRemove) { if (shouldRemove) {
return null // Mark for removal return null // Mark for removal
} }
@ -203,9 +207,7 @@ function cleanContentFromMedia(content: any, mediaIds: number[], urlsToRemove: s
// Clean gallery nodes // Clean gallery nodes
if (node.type === 'gallery' && node.attrs?.images) { if (node.type === 'gallery' && node.attrs?.images) {
const filteredImages = node.attrs.images.filter((image: any) => const filteredImages = node.attrs.images.filter((image: any) => !mediaIds.includes(image.id))
!mediaIds.includes(image.id)
)
if (filteredImages.length === 0) { if (filteredImages.length === 0) {
return null // Remove empty gallery return null // Remove empty gallery
@ -222,9 +224,7 @@ function cleanContentFromMedia(content: any, mediaIds: number[], urlsToRemove: s
// Recursively clean child nodes // Recursively clean child nodes
if (node.content) { if (node.content) {
const cleanedContent = node.content const cleanedContent = node.content.map(cleanNode).filter((child: any) => child !== null)
.map(cleanNode)
.filter((child: any) => child !== null)
return { return {
...node, ...node,

View file

@ -12,9 +12,21 @@ async function extractExifData(file: File): Promise<any> {
const buffer = await file.arrayBuffer() const buffer = await file.arrayBuffer()
const exif = await exifr.parse(buffer, { const exif = await exifr.parse(buffer, {
pick: [ pick: [
'Make', 'Model', 'LensModel', 'FocalLength', 'FNumber', 'ExposureTime', 'Make',
'ISO', 'DateTime', 'DateTimeOriginal', 'CreateDate', 'GPSLatitude', 'Model',
'GPSLongitude', 'GPSAltitude', 'Orientation', 'ColorSpace' 'LensModel',
'FocalLength',
'FNumber',
'ExposureTime',
'ISO',
'DateTime',
'DateTimeOriginal',
'CreateDate',
'GPSLatitude',
'GPSLongitude',
'GPSAltitude',
'Orientation',
'ColorSpace'
] ]
}) })
@ -77,7 +89,9 @@ async function extractExifData(file: File): Promise<any> {
return Object.keys(formattedExif).length > 0 ? formattedExif : null return Object.keys(formattedExif).length > 0 ? formattedExif : null
} catch (error) { } catch (error) {
logger.warn('Failed to extract EXIF data', { error: error instanceof Error ? error.message : 'Unknown error' }) logger.warn('Failed to extract EXIF data', {
error: error instanceof Error ? error.message : 'Unknown error'
})
return null return null
} }
} }
@ -183,7 +197,10 @@ export const POST: RequestHandler = async (event) => {
} catch (error) { } catch (error) {
logger.error('Media upload error', error as Error) logger.error('Media upload error', error as Error)
console.error('Detailed upload error:', error) console.error('Detailed upload error:', error)
return errorResponse(`Upload failed: ${error instanceof Error ? error.message : 'Unknown error'}`, 500) return errorResponse(
`Upload failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
500
)
} }
} }

View file

@ -66,8 +66,8 @@ export const GET: RequestHandler = async (event) => {
// Transform albums to PhotoAlbum format // Transform albums to PhotoAlbum format
const photoAlbums: PhotoAlbum[] = albums const photoAlbums: PhotoAlbum[] = albums
.filter(album => album.photos.length > 0) // Only include albums with published photos .filter((album) => album.photos.length > 0) // Only include albums with published photos
.map(album => ({ .map((album) => ({
id: `album-${album.id}`, id: `album-${album.id}`,
slug: album.slug, // Add slug for navigation slug: album.slug, // Add slug for navigation
title: album.title, title: album.title,
@ -80,7 +80,7 @@ export const GET: RequestHandler = async (event) => {
width: album.photos[0].width || 400, width: album.photos[0].width || 400,
height: album.photos[0].height || 400 height: album.photos[0].height || 400
}, },
photos: album.photos.map(photo => ({ photos: album.photos.map((photo) => ({
id: `photo-${photo.id}`, id: `photo-${photo.id}`,
src: photo.url, src: photo.url,
alt: photo.caption || photo.filename, alt: photo.caption || photo.filename,
@ -92,7 +92,7 @@ export const GET: RequestHandler = async (event) => {
})) }))
// Transform individual photos to Photo format // Transform individual photos to Photo format
const photos: Photo[] = individualPhotos.map(photo => ({ const photos: Photo[] = individualPhotos.map((photo) => ({
id: `photo-${photo.id}`, id: `photo-${photo.id}`,
src: photo.url, src: photo.url,
alt: photo.title || photo.caption || photo.filename, alt: photo.title || photo.caption || photo.filename,

View file

@ -45,13 +45,13 @@ export const GET: RequestHandler = async (event) => {
} }
// Find the specific photo // Find the specific photo
const photo = album.photos.find(p => p.id === photoId) const photo = album.photos.find((p) => p.id === photoId)
if (!photo) { if (!photo) {
return errorResponse('Photo not found in album', 404) return errorResponse('Photo not found in album', 404)
} }
// Get photo index for navigation // Get photo index for navigation
const photoIndex = album.photos.findIndex(p => p.id === photoId) const photoIndex = album.photos.findIndex((p) => p.id === photoId)
const prevPhoto = photoIndex > 0 ? album.photos[photoIndex - 1] : null const prevPhoto = photoIndex > 0 ? album.photos[photoIndex - 1] : null
const nextPhoto = photoIndex < album.photos.length - 1 ? album.photos[photoIndex + 1] : null const nextPhoto = photoIndex < album.photos.length - 1 ? album.photos[photoIndex + 1] : null

View file

@ -8,7 +8,11 @@ import {
checkAdminAuth checkAdminAuth
} from '$lib/server/api-utils' } from '$lib/server/api-utils'
import { logger } from '$lib/server/logger' import { logger } from '$lib/server/logger'
import { trackMediaUsage, extractMediaIds, type MediaUsageReference } from '$lib/server/media-usage.js' import {
trackMediaUsage,
extractMediaIds,
type MediaUsageReference
} from '$lib/server/media-usage.js'
// GET /api/posts - List all posts // GET /api/posts - List all posts
export const GET: RequestHandler = async (event) => { export const GET: RequestHandler = async (event) => {
@ -126,7 +130,8 @@ export const POST: RequestHandler = async (event) => {
linkUrl: data.link_url, linkUrl: data.link_url,
linkDescription: data.linkDescription, linkDescription: data.linkDescription,
featuredImage: featuredImageId, featuredImage: featuredImageId,
attachments: data.attachedPhotos && data.attachedPhotos.length > 0 ? data.attachedPhotos : null, attachments:
data.attachedPhotos && data.attachedPhotos.length > 0 ? data.attachedPhotos : null,
tags: data.tags, tags: data.tags,
publishedAt: data.publishedAt publishedAt: data.publishedAt
} }
@ -138,7 +143,7 @@ export const POST: RequestHandler = async (event) => {
// Track featured image // Track featured image
const featuredImageIds = extractMediaIds({ featuredImage: featuredImageId }, 'featuredImage') const featuredImageIds = extractMediaIds({ featuredImage: featuredImageId }, 'featuredImage')
featuredImageIds.forEach(mediaId => { featuredImageIds.forEach((mediaId) => {
usageReferences.push({ usageReferences.push({
mediaId, mediaId,
contentType: 'post', contentType: 'post',
@ -173,7 +178,7 @@ export const POST: RequestHandler = async (event) => {
// Track media in post content // Track media in post content
const contentIds = extractMediaIds({ content: postContent }, 'content') const contentIds = extractMediaIds({ content: postContent }, 'content')
contentIds.forEach(mediaId => { contentIds.forEach((mediaId) => {
usageReferences.push({ usageReferences.push({
mediaId, mediaId,
contentType: 'post', contentType: 'post',

View file

@ -2,7 +2,13 @@ import type { RequestHandler } from './$types'
import { prisma } from '$lib/server/database' import { prisma } from '$lib/server/database'
import { jsonResponse, errorResponse, checkAdminAuth } from '$lib/server/api-utils' import { jsonResponse, errorResponse, checkAdminAuth } from '$lib/server/api-utils'
import { logger } from '$lib/server/logger' import { logger } from '$lib/server/logger'
import { updateMediaUsage, removeMediaUsage, extractMediaIds, trackMediaUsage, type MediaUsageReference } from '$lib/server/media-usage.js' import {
updateMediaUsage,
removeMediaUsage,
extractMediaIds,
trackMediaUsage,
type MediaUsageReference
} from '$lib/server/media-usage.js'
// GET /api/posts/[id] - Get a single post // GET /api/posts/[id] - Get a single post
export const GET: RequestHandler = async (event) => { export const GET: RequestHandler = async (event) => {
@ -93,7 +99,8 @@ export const PUT: RequestHandler = async (event) => {
linkUrl: data.link_url, linkUrl: data.link_url,
linkDescription: data.linkDescription, linkDescription: data.linkDescription,
featuredImage: featuredImageId, featuredImage: featuredImageId,
attachments: data.attachedPhotos && data.attachedPhotos.length > 0 ? data.attachedPhotos : null, attachments:
data.attachedPhotos && data.attachedPhotos.length > 0 ? data.attachedPhotos : null,
tags: data.tags, tags: data.tags,
publishedAt: data.publishedAt publishedAt: data.publishedAt
} }
@ -109,7 +116,7 @@ export const PUT: RequestHandler = async (event) => {
// Track featured image // Track featured image
const featuredImageIds = extractMediaIds(post, 'featuredImage') const featuredImageIds = extractMediaIds(post, 'featuredImage')
featuredImageIds.forEach(mediaId => { featuredImageIds.forEach((mediaId) => {
usageReferences.push({ usageReferences.push({
mediaId, mediaId,
contentType: 'post', contentType: 'post',
@ -120,7 +127,7 @@ export const PUT: RequestHandler = async (event) => {
// Track attachments // Track attachments
const attachmentIds = extractMediaIds(post, 'attachments') const attachmentIds = extractMediaIds(post, 'attachments')
attachmentIds.forEach(mediaId => { attachmentIds.forEach((mediaId) => {
usageReferences.push({ usageReferences.push({
mediaId, mediaId,
contentType: 'post', contentType: 'post',
@ -131,7 +138,7 @@ export const PUT: RequestHandler = async (event) => {
// Track media in post content // Track media in post content
const contentIds = extractMediaIds(post, 'content') const contentIds = extractMediaIds(post, 'content')
contentIds.forEach(mediaId => { contentIds.forEach((mediaId) => {
usageReferences.push({ usageReferences.push({
mediaId, mediaId,
contentType: 'post', contentType: 'post',

View file

@ -10,7 +10,11 @@ import {
} from '$lib/server/api-utils' } from '$lib/server/api-utils'
import { logger } from '$lib/server/logger' import { logger } from '$lib/server/logger'
import { createSlug, ensureUniqueSlug } from '$lib/server/database' import { createSlug, ensureUniqueSlug } from '$lib/server/database'
import { trackMediaUsage, extractMediaIds, type MediaUsageReference } from '$lib/server/media-usage.js' import {
trackMediaUsage,
extractMediaIds,
type MediaUsageReference
} from '$lib/server/media-usage.js'
// GET /api/projects - List all projects // GET /api/projects - List all projects
export const GET: RequestHandler = async (event) => { export const GET: RequestHandler = async (event) => {
@ -22,7 +26,8 @@ export const GET: RequestHandler = async (event) => {
const status = event.url.searchParams.get('status') const status = event.url.searchParams.get('status')
const projectType = event.url.searchParams.get('projectType') const projectType = event.url.searchParams.get('projectType')
const includeListOnly = event.url.searchParams.get('includeListOnly') === 'true' const includeListOnly = event.url.searchParams.get('includeListOnly') === 'true'
const includePasswordProtected = event.url.searchParams.get('includePasswordProtected') === 'true' const includePasswordProtected =
event.url.searchParams.get('includePasswordProtected') === 'true'
// Build where clause // Build where clause
const where: any = {} const where: any = {}
@ -126,7 +131,7 @@ export const POST: RequestHandler = async (event) => {
// Track featured image // Track featured image
const featuredImageIds = extractMediaIds(body, 'featuredImage') const featuredImageIds = extractMediaIds(body, 'featuredImage')
featuredImageIds.forEach(mediaId => { featuredImageIds.forEach((mediaId) => {
usageReferences.push({ usageReferences.push({
mediaId, mediaId,
contentType: 'project', contentType: 'project',
@ -137,7 +142,7 @@ export const POST: RequestHandler = async (event) => {
// Track logo // Track logo
const logoIds = extractMediaIds(body, 'logoUrl') const logoIds = extractMediaIds(body, 'logoUrl')
logoIds.forEach(mediaId => { logoIds.forEach((mediaId) => {
usageReferences.push({ usageReferences.push({
mediaId, mediaId,
contentType: 'project', contentType: 'project',
@ -148,7 +153,7 @@ export const POST: RequestHandler = async (event) => {
// Track gallery images // Track gallery images
const galleryIds = extractMediaIds(body, 'gallery') const galleryIds = extractMediaIds(body, 'gallery')
galleryIds.forEach(mediaId => { galleryIds.forEach((mediaId) => {
usageReferences.push({ usageReferences.push({
mediaId, mediaId,
contentType: 'project', contentType: 'project',
@ -159,7 +164,7 @@ export const POST: RequestHandler = async (event) => {
// Track media in case study content // Track media in case study content
const contentIds = extractMediaIds(body, 'caseStudyContent') const contentIds = extractMediaIds(body, 'caseStudyContent')
contentIds.forEach(mediaId => { contentIds.forEach((mediaId) => {
usageReferences.push({ usageReferences.push({
mediaId, mediaId,
contentType: 'project', contentType: 'project',

View file

@ -8,7 +8,12 @@ import {
} from '$lib/server/api-utils' } from '$lib/server/api-utils'
import { logger } from '$lib/server/logger' import { logger } from '$lib/server/logger'
import { ensureUniqueSlug } from '$lib/server/database' import { ensureUniqueSlug } from '$lib/server/database'
import { updateMediaUsage, removeMediaUsage, extractMediaIds, type MediaUsageReference } from '$lib/server/media-usage.js' import {
updateMediaUsage,
removeMediaUsage,
extractMediaIds,
type MediaUsageReference
} from '$lib/server/media-usage.js'
// GET /api/projects/[id] - Get a single project // GET /api/projects/[id] - Get a single project
export const GET: RequestHandler = async (event) => { export const GET: RequestHandler = async (event) => {
@ -103,7 +108,7 @@ export const PUT: RequestHandler = async (event) => {
// Track featured image // Track featured image
const featuredImageIds = extractMediaIds(project, 'featuredImage') const featuredImageIds = extractMediaIds(project, 'featuredImage')
featuredImageIds.forEach(mediaId => { featuredImageIds.forEach((mediaId) => {
usageReferences.push({ usageReferences.push({
mediaId, mediaId,
contentType: 'project', contentType: 'project',
@ -114,7 +119,7 @@ export const PUT: RequestHandler = async (event) => {
// Track logo // Track logo
const logoIds = extractMediaIds(project, 'logoUrl') const logoIds = extractMediaIds(project, 'logoUrl')
logoIds.forEach(mediaId => { logoIds.forEach((mediaId) => {
usageReferences.push({ usageReferences.push({
mediaId, mediaId,
contentType: 'project', contentType: 'project',
@ -125,7 +130,7 @@ export const PUT: RequestHandler = async (event) => {
// Track gallery images // Track gallery images
const galleryIds = extractMediaIds(project, 'gallery') const galleryIds = extractMediaIds(project, 'gallery')
galleryIds.forEach(mediaId => { galleryIds.forEach((mediaId) => {
usageReferences.push({ usageReferences.push({
mediaId, mediaId,
contentType: 'project', contentType: 'project',
@ -136,7 +141,7 @@ export const PUT: RequestHandler = async (event) => {
// Track media in case study content // Track media in case study content
const contentIds = extractMediaIds(project, 'caseStudyContent') const contentIds = extractMediaIds(project, 'caseStudyContent')
contentIds.forEach(mediaId => { contentIds.forEach((mediaId) => {
usageReferences.push({ usageReferences.push({
mediaId, mediaId,
contentType: 'project', contentType: 'project',

View file

@ -1,7 +1,7 @@
import 'dotenv/config' import 'dotenv/config'
import { error, json } from '@sveltejs/kit' import { error, json } from '@sveltejs/kit'
import redis from '../redis-client' import redis from '../redis-client'
import SteamAPI, { Game, GameInfo, GameInfoExtended, UserPlaytime } from 'steamapi' import SteamAPI from 'steamapi'
import type { RequestHandler } from './$types' import type { RequestHandler } from './$types'

View file

@ -88,7 +88,7 @@ export const GET: RequestHandler = async (event) => {
}) })
// Transform posts to universe items // Transform posts to universe items
const postItems: UniverseItem[] = posts.map(post => ({ const postItems: UniverseItem[] = posts.map((post) => ({
id: post.id, id: post.id,
type: 'post' as const, type: 'post' as const,
slug: post.slug, slug: post.slug,
@ -104,7 +104,7 @@ export const GET: RequestHandler = async (event) => {
})) }))
// Transform albums to universe items // Transform albums to universe items
const albumItems: UniverseItem[] = albums.map(album => ({ const albumItems: UniverseItem[] = albums.map((album) => ({
id: album.id, id: album.id,
type: 'album' as const, type: 'album' as const,
slug: album.slug, slug: album.slug,
@ -120,8 +120,9 @@ export const GET: RequestHandler = async (event) => {
})) }))
// Combine and sort by publishedAt // Combine and sort by publishedAt
const allItems = [...postItems, ...albumItems] const allItems = [...postItems, ...albumItems].sort(
.sort((a, b) => new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime()) (a, b) => new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime()
)
// Apply pagination // Apply pagination
const paginatedItems = allItems.slice(offset, offset + limit) const paginatedItems = allItems.slice(offset, offset + limit)

View file

@ -2,7 +2,9 @@ import type { PageLoad } from './$types'
export const load: PageLoad = async ({ fetch }) => { export const load: PageLoad = async ({ fetch }) => {
try { try {
const response = await fetch('/api/projects?projectType=labs&includeListOnly=true&includePasswordProtected=true') const response = await fetch(
'/api/projects?projectType=labs&includeListOnly=true&includePasswordProtected=true'
)
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to fetch labs projects') throw new Error('Failed to fetch labs projects')
} }

View file

@ -37,11 +37,18 @@
</Page> </Page>
{:else if project.status === 'password-protected'} {:else if project.status === 'password-protected'}
<Page> <Page>
<ProjectPasswordProtection projectSlug={project.slug} correctPassword={project.password || ''} projectType="labs"> <ProjectPasswordProtection
projectSlug={project.slug}
correctPassword={project.password || ''}
projectType="labs"
>
{#snippet children()} {#snippet children()}
<div slot="header" class="project-header"> <div slot="header" class="project-header">
{#if project.logoUrl} {#if project.logoUrl}
<div class="project-logo" style="background-color: {project.backgroundColor || '#f5f5f5'}"> <div
class="project-logo"
style="background-color: {project.backgroundColor || '#f5f5f5'}"
>
<img src={project.logoUrl} alt="{project.title} logo" /> <img src={project.logoUrl} alt="{project.title} logo" />
</div> </div>
{/if} {/if}

View file

@ -4,7 +4,9 @@ import type { Project } from '$lib/types/project'
export const load: PageLoad = async ({ params, fetch }) => { export const load: PageLoad = async ({ params, fetch }) => {
try { try {
// Find project by slug - we'll fetch all published, list-only, and password-protected projects // Find project by slug - we'll fetch all published, list-only, and password-protected projects
const response = await fetch(`/api/projects?projectType=labs&includeListOnly=true&includePasswordProtected=true`) const response = await fetch(
`/api/projects?projectType=labs&includeListOnly=true&includePasswordProtected=true`
)
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to fetch projects') throw new Error('Failed to fetch projects')
} }

View file

@ -34,7 +34,9 @@
exifData.aperture, exifData.aperture,
formatSpeed(exifData.shutterSpeed), formatSpeed(exifData.shutterSpeed),
exifData.iso ? `ISO ${exifData.iso}` : null exifData.iso ? `ISO ${exifData.iso}` : null
].filter(Boolean).join(' • '), ]
.filter(Boolean)
.join(' • '),
location: exifData.location, location: exifData.location,
dateTaken: exifData.dateTaken dateTaken: exifData.dateTaken
} }
@ -45,12 +47,25 @@
<svelte:head> <svelte:head>
{#if photo && album} {#if photo && album}
<title>{photo.title || photo.caption || `Photo ${navigation?.currentIndex}`} - {album.title}</title> <title
<meta name="description" content={photo.description || photo.caption || `Photo from ${album.title}`} /> >{photo.title || photo.caption || `Photo ${navigation?.currentIndex}`} - {album.title}</title
>
<meta
name="description"
content={photo.description || photo.caption || `Photo from ${album.title}`}
/>
<!-- Open Graph meta tags --> <!-- Open Graph meta tags -->
<meta property="og:title" content="{photo.title || photo.caption || `Photo ${navigation?.currentIndex}`} - {album.title}" /> <meta
<meta property="og:description" content={photo.description || photo.caption || `Photo from ${album.title}`} /> property="og:title"
content="{photo.title ||
photo.caption ||
`Photo ${navigation?.currentIndex}`} - {album.title}"
/>
<meta
property="og:description"
content={photo.description || photo.caption || `Photo from ${album.title}`}
/>
<meta property="og:type" content="article" /> <meta property="og:type" content="article" />
<meta property="og:image" content={photo.url} /> <meta property="og:image" content={photo.url} />
@ -68,7 +83,7 @@
<div class="error-container"> <div class="error-container">
<div class="error-content"> <div class="error-content">
<h1>Photo Not Found</h1> <h1>Photo Not Found</h1>
<p>{error || 'The photo you\'re looking for doesn\'t exist.'}</p> <p>{error || "The photo you're looking for doesn't exist."}</p>
<a href="/photos" class="back-link">← Back to Photos</a> <a href="/photos" class="back-link">← Back to Photos</a>
</div> </div>
</div> </div>
@ -88,14 +103,26 @@
{#if navigation.prevPhoto} {#if navigation.prevPhoto}
<a href="/photos/{album.slug}/{navigation.prevPhoto.id}" class="nav-btn prev"> <a href="/photos/{album.slug}/{navigation.prevPhoto.id}" class="nav-btn prev">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none"> <svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path d="M12.5 15L7.5 10L12.5 5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> <path
d="M12.5 15L7.5 10L12.5 5"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg> </svg>
Previous Previous
</a> </a>
{:else} {:else}
<div class="nav-btn disabled"> <div class="nav-btn disabled">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none"> <svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path d="M12.5 15L7.5 10L12.5 5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> <path
d="M12.5 15L7.5 10L12.5 5"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg> </svg>
Previous Previous
</div> </div>
@ -105,14 +132,26 @@
<a href="/photos/{album.slug}/{navigation.nextPhoto.id}" class="nav-btn next"> <a href="/photos/{album.slug}/{navigation.nextPhoto.id}" class="nav-btn next">
Next Next
<svg width="20" height="20" viewBox="0 0 20 20" fill="none"> <svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path d="M7.5 5L12.5 10L7.5 15" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> <path
d="M7.5 5L12.5 10L7.5 15"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg> </svg>
</a> </a>
{:else} {:else}
<div class="nav-btn disabled"> <div class="nav-btn disabled">
Next Next
<svg width="20" height="20" viewBox="0 0 20 20" fill="none"> <svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path d="M7.5 5L12.5 10L7.5 15" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> <path
d="M7.5 5L12.5 10L7.5 15"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg> </svg>
</div> </div>
{/if} {/if}
@ -241,16 +280,16 @@
min-height: 100vh; min-height: 100vh;
display: grid; display: grid;
grid-template-areas: grid-template-areas:
"header header" 'header header'
"main details"; 'main details';
grid-template-columns: 1fr 400px; grid-template-columns: 1fr 400px;
grid-template-rows: auto 1fr; grid-template-rows: auto 1fr;
@include breakpoint('tablet') { @include breakpoint('tablet') {
grid-template-areas: grid-template-areas:
"header" 'header'
"main" 'main'
"details"; 'details';
grid-template-columns: 1fr; grid-template-columns: 1fr;
grid-template-rows: auto 1fr auto; grid-template-rows: auto 1fr auto;
} }

View file

@ -8,14 +8,16 @@
const error = $derived(data.error) const error = $derived(data.error)
// Transform album data to PhotoItem format for PhotoGrid // Transform album data to PhotoItem format for PhotoGrid
const photoItems = $derived(album?.photos?.map((photo: any) => ({ const photoItems = $derived(
album?.photos?.map((photo: any) => ({
id: `photo-${photo.id}`, id: `photo-${photo.id}`,
src: photo.url, src: photo.url,
alt: photo.caption || photo.filename, alt: photo.caption || photo.filename,
caption: photo.caption, caption: photo.caption,
width: photo.width || 400, width: photo.width || 400,
height: photo.height || 400 height: photo.height || 400
})) ?? []) })) ?? []
)
const formatDate = (dateString: string) => { const formatDate = (dateString: string) => {
const date = new Date(dateString) const date = new Date(dateString)
@ -68,13 +70,15 @@
{#if album.location} {#if album.location}
<span class="meta-item">📍 {album.location}</span> <span class="meta-item">📍 {album.location}</span>
{/if} {/if}
<span class="meta-item">📷 {album.photos?.length || 0} photo{(album.photos?.length || 0) !== 1 ? 's' : ''}</span> <span class="meta-item"
>📷 {album.photos?.length || 0} photo{(album.photos?.length || 0) !== 1 ? 's' : ''}</span
>
</div> </div>
</div> </div>
<!-- Photo Grid --> <!-- Photo Grid -->
{#if photoItems.length > 0} {#if photoItems.length > 0}
<PhotoGrid photoItems={photoItems} albumSlug={album.slug} /> <PhotoGrid {photoItems} albumSlug={album.slug} />
{:else} {:else}
<div class="empty-album"> <div class="empty-album">
<p>This album doesn't contain any photos yet.</p> <p>This album doesn't contain any photos yet.</p>

View file

@ -26,7 +26,9 @@ function convertContentToHTML(content: any): string {
const level = block.level || 2 const level = block.level || 2
return `<h${level}>${escapeXML(block.content || '')}</h${level}>` return `<h${level}>${escapeXML(block.content || '')}</h${level}>`
case 'list': case 'list':
const items = (block.content || []).map((item: any) => `<li>${escapeXML(item)}</li>`).join('') const items = (block.content || [])
.map((item: any) => `<li>${escapeXML(item)}</li>`)
.join('')
return block.listType === 'ordered' ? `<ol>${items}</ol>` : `<ul>${items}</ul>` return block.listType === 'ordered' ? `<ol>${items}</ol>` : `<ul>${items}</ul>`
default: default:
return `<p>${escapeXML(block.content || '')}</p>` return `<p>${escapeXML(block.content || '')}</p>`
@ -119,11 +121,12 @@ export const GET: RequestHandler = async (event) => {
// Combine all content types // Combine all content types
const items = [ const items = [
...posts.map(post => ({ ...posts.map((post) => ({
type: 'post', type: 'post',
section: 'universe', section: 'universe',
id: post.id.toString(), id: post.id.toString(),
title: post.title || `${post.postType.charAt(0).toUpperCase() + post.postType.slice(1)} Post`, title:
post.title || `${post.postType.charAt(0).toUpperCase() + post.postType.slice(1)} Post`,
description: post.excerpt || extractTextSummary(post.content) || '', description: post.excerpt || extractTextSummary(post.content) || '',
content: convertContentToHTML(post.content), content: convertContentToHTML(post.content),
link: `${event.url.origin}/universe/${post.slug}`, link: `${event.url.origin}/universe/${post.slug}`,
@ -133,12 +136,14 @@ export const GET: RequestHandler = async (event) => {
postType: post.postType, postType: post.postType,
linkUrl: post.linkUrl || null linkUrl: post.linkUrl || null
})), })),
...universeAlbums.map(album => ({ ...universeAlbums.map((album) => ({
type: 'album', type: 'album',
section: 'universe', section: 'universe',
id: album.id.toString(), id: album.id.toString(),
title: album.title, title: album.title,
description: album.description || `Photo album with ${album._count.photos} photo${album._count.photos !== 1 ? 's' : ''}`, description:
album.description ||
`Photo album with ${album._count.photos} photo${album._count.photos !== 1 ? 's' : ''}`,
content: album.description ? `<p>${escapeXML(album.description)}</p>` : '', content: album.description ? `<p>${escapeXML(album.description)}</p>` : '',
link: `${event.url.origin}/photos/${album.slug}`, link: `${event.url.origin}/photos/${album.slug}`,
guid: `${event.url.origin}/photos/${album.slug}`, guid: `${event.url.origin}/photos/${album.slug}`,
@ -149,13 +154,15 @@ export const GET: RequestHandler = async (event) => {
location: album.location location: album.location
})), })),
...photoAlbums ...photoAlbums
.filter(album => !universeAlbums.some(ua => ua.id === album.id)) // Avoid duplicates .filter((album) => !universeAlbums.some((ua) => ua.id === album.id)) // Avoid duplicates
.map(album => ({ .map((album) => ({
type: 'album', type: 'album',
section: 'photos', section: 'photos',
id: album.id.toString(), id: album.id.toString(),
title: album.title, title: album.title,
description: album.description || `Photography album${album.location ? ` from ${album.location}` : ''} with ${album._count.photos} photo${album._count.photos !== 1 ? 's' : ''}`, description:
album.description ||
`Photography album${album.location ? ` from ${album.location}` : ''} with ${album._count.photos} photo${album._count.photos !== 1 ? 's' : ''}`,
content: album.description ? `<p>${escapeXML(album.description)}</p>` : '', content: album.description ? `<p>${escapeXML(album.description)}</p>` : '',
link: `${event.url.origin}/photos/${album.slug}`, link: `${event.url.origin}/photos/${album.slug}`,
guid: `${event.url.origin}/photos/${album.slug}`, guid: `${event.url.origin}/photos/${album.slug}`,
@ -186,7 +193,9 @@ export const GET: RequestHandler = async (event) => {
<generator>SvelteKit RSS Generator</generator> <generator>SvelteKit RSS Generator</generator>
<docs>https://cyber.harvard.edu/rss/rss.html</docs> <docs>https://cyber.harvard.edu/rss/rss.html</docs>
<ttl>60</ttl> <ttl>60</ttl>
${items.map(item => ` ${items
.map(
(item) => `
<item> <item>
<title>${escapeXML(item.title)}</title> <title>${escapeXML(item.title)}</title>
<description><![CDATA[${item.description}]]></description> <description><![CDATA[${item.description}]]></description>
@ -198,13 +207,19 @@ ${item.updatedDate ? `<atom:updated>${new Date(item.updatedDate).toISOString()}<
<category>${item.section}</category> <category>${item.section}</category>
<category>${item.type === 'post' ? item.postType : 'album'}</category> <category>${item.type === 'post' ? item.postType : 'album'}</category>
${item.type === 'post' && item.linkUrl ? `<comments>${item.linkUrl}</comments>` : ''} ${item.type === 'post' && item.linkUrl ? `<comments>${item.linkUrl}</comments>` : ''}
${item.type === 'album' && item.coverPhoto ? ` ${
item.type === 'album' && item.coverPhoto
? `
<enclosure url="${event.url.origin}${item.coverPhoto.url}" type="image/jpeg" length="0"/> <enclosure url="${event.url.origin}${item.coverPhoto.url}" type="image/jpeg" length="0"/>
<media:thumbnail url="${event.url.origin}${item.coverPhoto.thumbnailUrl || item.coverPhoto.url}"/> <media:thumbnail url="${event.url.origin}${item.coverPhoto.thumbnailUrl || item.coverPhoto.url}"/>
<media:content url="${event.url.origin}${item.coverPhoto.url}" type="image/jpeg"/>` : ''} <media:content url="${event.url.origin}${item.coverPhoto.url}" type="image/jpeg"/>`
: ''
}
${item.location ? `<category domain="location">${escapeXML(item.location)}</category>` : ''} ${item.location ? `<category domain="location">${escapeXML(item.location)}</category>` : ''}
<author>noreply@jedmund.com (Justin Edmund)</author> <author>noreply@jedmund.com (Justin Edmund)</author>
</item>`).join('')} </item>`
)
.join('')}
</channel> </channel>
</rss>` </rss>`
@ -215,9 +230,9 @@ ${item.location ? `<category domain="location">${escapeXML(item.location)}</cate
'Content-Type': 'application/rss+xml; charset=utf-8', 'Content-Type': 'application/rss+xml; charset=utf-8',
'Cache-Control': 'public, max-age=3600, stale-while-revalidate=86400', 'Cache-Control': 'public, max-age=3600, stale-while-revalidate=86400',
'Last-Modified': lastBuildDate, 'Last-Modified': lastBuildDate,
'ETag': `"${Buffer.from(rssXml).toString('base64').slice(0, 16)}"`, ETag: `"${Buffer.from(rssXml).toString('base64').slice(0, 16)}"`,
'X-Content-Type-Options': 'nosniff', 'X-Content-Type-Options': 'nosniff',
'Vary': 'Accept-Encoding' Vary: 'Accept-Encoding'
} }
}) })
} catch (error) { } catch (error) {

View file

@ -63,11 +63,13 @@ export const GET: RequestHandler = async (event) => {
// Combine albums and standalone photos // Combine albums and standalone photos
const items = [ const items = [
...albums.map(album => ({ ...albums.map((album) => ({
type: 'album', type: 'album',
id: album.id.toString(), id: album.id.toString(),
title: album.title, title: album.title,
description: album.description || `Photography album${album.location ? ` from ${album.location}` : ''} with ${album._count.photos} photo${album._count.photos !== 1 ? 's' : ''}`, description:
album.description ||
`Photography album${album.location ? ` from ${album.location}` : ''} with ${album._count.photos} photo${album._count.photos !== 1 ? 's' : ''}`,
content: album.description ? `<p>${escapeXML(album.description)}</p>` : '', content: album.description ? `<p>${escapeXML(album.description)}</p>` : '',
link: `${event.url.origin}/photos/${album.slug}`, link: `${event.url.origin}/photos/${album.slug}`,
pubDate: album.createdAt, pubDate: album.createdAt,
@ -78,12 +80,16 @@ export const GET: RequestHandler = async (event) => {
location: album.location, location: album.location,
date: album.date date: album.date
})), })),
...standalonePhotos.map(photo => ({ ...standalonePhotos.map((photo) => ({
type: 'photo', type: 'photo',
id: photo.id.toString(), id: photo.id.toString(),
title: photo.title || photo.filename, title: photo.title || photo.filename,
description: photo.description || photo.caption || `Photo: ${photo.filename}`, description: photo.description || photo.caption || `Photo: ${photo.filename}`,
content: photo.description ? `<p>${escapeXML(photo.description)}</p>` : (photo.caption ? `<p>${escapeXML(photo.caption)}</p>` : ''), content: photo.description
? `<p>${escapeXML(photo.description)}</p>`
: photo.caption
? `<p>${escapeXML(photo.caption)}</p>`
: '',
link: `${event.url.origin}/photos/photo/${photo.slug || photo.id}`, link: `${event.url.origin}/photos/photo/${photo.slug || photo.id}`,
pubDate: photo.publishedAt || photo.createdAt, pubDate: photo.publishedAt || photo.createdAt,
updatedDate: photo.updatedAt, updatedDate: photo.updatedAt,
@ -111,7 +117,9 @@ export const GET: RequestHandler = async (event) => {
<generator>SvelteKit RSS Generator</generator> <generator>SvelteKit RSS Generator</generator>
<docs>https://cyber.harvard.edu/rss/rss.html</docs> <docs>https://cyber.harvard.edu/rss/rss.html</docs>
<ttl>60</ttl> <ttl>60</ttl>
${items.map(item => ` ${items
.map(
(item) => `
<item> <item>
<title>${escapeXML(item.title)}</title> <title>${escapeXML(item.title)}</title>
<description><![CDATA[${item.description}]]></description> <description><![CDATA[${item.description}]]></description>
@ -121,17 +129,27 @@ ${item.content ? `<content:encoded><![CDATA[${item.content}]]></content:encoded>
<pubDate>${formatRFC822Date(new Date(item.pubDate))}</pubDate> <pubDate>${formatRFC822Date(new Date(item.pubDate))}</pubDate>
${item.updatedDate ? `<atom:updated>${new Date(item.updatedDate).toISOString()}</atom:updated>` : ''} ${item.updatedDate ? `<atom:updated>${new Date(item.updatedDate).toISOString()}</atom:updated>` : ''}
<category>${item.type}</category> <category>${item.type}</category>
${item.type === 'album' && item.coverPhoto ? ` ${
item.type === 'album' && item.coverPhoto
? `
<enclosure url="${event.url.origin}${item.coverPhoto.url}" type="image/jpeg" length="0"/> <enclosure url="${event.url.origin}${item.coverPhoto.url}" type="image/jpeg" length="0"/>
<media:thumbnail url="${event.url.origin}${item.coverPhoto.thumbnailUrl || item.coverPhoto.url}"/> <media:thumbnail url="${event.url.origin}${item.coverPhoto.thumbnailUrl || item.coverPhoto.url}"/>
<media:content url="${event.url.origin}${item.coverPhoto.url}" type="image/jpeg"/>` : ''} <media:content url="${event.url.origin}${item.coverPhoto.url}" type="image/jpeg"/>`
${item.type === 'photo' ? ` : ''
}
${
item.type === 'photo'
? `
<enclosure url="${event.url.origin}${item.url}" type="image/jpeg" length="0"/> <enclosure url="${event.url.origin}${item.url}" type="image/jpeg" length="0"/>
<media:thumbnail url="${event.url.origin}${item.thumbnailUrl || item.url}"/> <media:thumbnail url="${event.url.origin}${item.thumbnailUrl || item.url}"/>
<media:content url="${event.url.origin}${item.url}" type="image/jpeg"/>` : ''} <media:content url="${event.url.origin}${item.url}" type="image/jpeg"/>`
: ''
}
${item.location ? `<category domain="location">${escapeXML(item.location)}</category>` : ''} ${item.location ? `<category domain="location">${escapeXML(item.location)}</category>` : ''}
<author>noreply@jedmund.com (Justin Edmund)</author> <author>noreply@jedmund.com (Justin Edmund)</author>
</item>`).join('')} </item>`
)
.join('')}
</channel> </channel>
</rss>` </rss>`
@ -142,9 +160,9 @@ ${item.location ? `<category domain="location">${escapeXML(item.location)}</cate
'Content-Type': 'application/rss+xml; charset=utf-8', 'Content-Type': 'application/rss+xml; charset=utf-8',
'Cache-Control': 'public, max-age=3600, stale-while-revalidate=86400', 'Cache-Control': 'public, max-age=3600, stale-while-revalidate=86400',
'Last-Modified': lastBuildDate, 'Last-Modified': lastBuildDate,
'ETag': `"${Buffer.from(rssXml).toString('base64').slice(0, 16)}"`, ETag: `"${Buffer.from(rssXml).toString('base64').slice(0, 16)}"`,
'X-Content-Type-Options': 'nosniff', 'X-Content-Type-Options': 'nosniff',
'Vary': 'Accept-Encoding' Vary: 'Accept-Encoding'
} }
}) })
} catch (error) { } catch (error) {

View file

@ -26,7 +26,9 @@ function convertContentToHTML(content: any): string {
const level = block.level || 2 const level = block.level || 2
return `<h${level}>${escapeXML(block.content || '')}</h${level}>` return `<h${level}>${escapeXML(block.content || '')}</h${level}>`
case 'list': case 'list':
const items = (block.content || []).map((item: any) => `<li>${escapeXML(item)}</li>`).join('') const items = (block.content || [])
.map((item: any) => `<li>${escapeXML(item)}</li>`)
.join('')
return block.listType === 'ordered' ? `<ol>${items}</ol>` : `<ul>${items}</ul>` return block.listType === 'ordered' ? `<ol>${items}</ol>` : `<ul>${items}</ul>`
default: default:
return `<p>${escapeXML(block.content || '')}</p>` return `<p>${escapeXML(block.content || '')}</p>`
@ -81,10 +83,11 @@ export const GET: RequestHandler = async (event) => {
// Combine and sort by date // Combine and sort by date
const items = [ const items = [
...posts.map(post => ({ ...posts.map((post) => ({
type: 'post', type: 'post',
id: post.id.toString(), id: post.id.toString(),
title: post.title || `${post.postType.charAt(0).toUpperCase() + post.postType.slice(1)} Post`, title:
post.title || `${post.postType.charAt(0).toUpperCase() + post.postType.slice(1)} Post`,
description: post.excerpt || extractTextSummary(post.content) || '', description: post.excerpt || extractTextSummary(post.content) || '',
content: convertContentToHTML(post.content), content: convertContentToHTML(post.content),
link: `${event.url.origin}/universe/${post.slug}`, link: `${event.url.origin}/universe/${post.slug}`,
@ -94,11 +97,13 @@ export const GET: RequestHandler = async (event) => {
postType: post.postType, postType: post.postType,
linkUrl: post.linkUrl || null linkUrl: post.linkUrl || null
})), })),
...albums.map(album => ({ ...albums.map((album) => ({
type: 'album', type: 'album',
id: album.id.toString(), id: album.id.toString(),
title: album.title, title: album.title,
description: album.description || `Photo album with ${album._count.photos} photo${album._count.photos !== 1 ? 's' : ''}`, description:
album.description ||
`Photo album with ${album._count.photos} photo${album._count.photos !== 1 ? 's' : ''}`,
content: album.description ? `<p>${escapeXML(album.description)}</p>` : '', content: album.description ? `<p>${escapeXML(album.description)}</p>` : '',
link: `${event.url.origin}/photos/${album.slug}`, link: `${event.url.origin}/photos/${album.slug}`,
guid: `${event.url.origin}/photos/${album.slug}`, guid: `${event.url.origin}/photos/${album.slug}`,
@ -126,7 +131,9 @@ export const GET: RequestHandler = async (event) => {
<generator>SvelteKit RSS Generator</generator> <generator>SvelteKit RSS Generator</generator>
<docs>https://cyber.harvard.edu/rss/rss.html</docs> <docs>https://cyber.harvard.edu/rss/rss.html</docs>
<ttl>60</ttl> <ttl>60</ttl>
${items.map(item => ` ${items
.map(
(item) => `
<item> <item>
<title>${escapeXML(item.title)}</title> <title>${escapeXML(item.title)}</title>
<description><![CDATA[${item.description}]]></description> <description><![CDATA[${item.description}]]></description>
@ -138,7 +145,9 @@ ${item.updatedDate ? `<atom:updated>${new Date(item.updatedDate).toISOString()}<
<category>${item.type === 'post' ? item.postType : 'album'}</category> <category>${item.type === 'post' ? item.postType : 'album'}</category>
${item.type === 'post' && item.linkUrl ? `<comments>${item.linkUrl}</comments>` : ''} ${item.type === 'post' && item.linkUrl ? `<comments>${item.linkUrl}</comments>` : ''}
<author>noreply@jedmund.com (Justin Edmund)</author> <author>noreply@jedmund.com (Justin Edmund)</author>
</item>`).join('')} </item>`
)
.join('')}
</channel> </channel>
</rss>` </rss>`
@ -149,9 +158,9 @@ ${item.type === 'post' && item.linkUrl ? `<comments>${item.linkUrl}</comments>`
'Content-Type': 'application/rss+xml; charset=utf-8', 'Content-Type': 'application/rss+xml; charset=utf-8',
'Cache-Control': 'public, max-age=3600, stale-while-revalidate=86400', 'Cache-Control': 'public, max-age=3600, stale-while-revalidate=86400',
'Last-Modified': lastBuildDate, 'Last-Modified': lastBuildDate,
'ETag': `"${Buffer.from(rssXml).toString('base64').slice(0, 16)}"`, ETag: `"${Buffer.from(rssXml).toString('base64').slice(0, 16)}"`,
'X-Content-Type-Options': 'nosniff', 'X-Content-Type-Options': 'nosniff',
'Vary': 'Accept-Encoding' Vary: 'Accept-Encoding'
} }
}) })
} catch (error) { } catch (error) {

View file

@ -13,11 +13,17 @@
<svelte:head> <svelte:head>
{#if post} {#if post}
<title>{pageTitle} - jedmund</title> <title>{pageTitle} - jedmund</title>
<meta name="description" content={post.excerpt || `${post.postType === 'essay' ? 'Essay' : 'Post'} by jedmund`} /> <meta
name="description"
content={post.excerpt || `${post.postType === 'essay' ? 'Essay' : 'Post'} by jedmund`}
/>
<!-- Open Graph meta tags --> <!-- Open Graph meta tags -->
<meta property="og:title" content={pageTitle} /> <meta property="og:title" content={pageTitle} />
<meta property="og:description" content={post.excerpt || `${post.postType === 'essay' ? 'Essay' : 'Post'} by jedmund`} /> <meta
property="og:description"
content={post.excerpt || `${post.postType === 'essay' ? 'Essay' : 'Post'} by jedmund`}
/>
<meta property="og:type" content="article" /> <meta property="og:type" content="article" />
{#if post.attachments && post.attachments.length > 0} {#if post.attachments && post.attachments.length > 0}
<meta property="og:image" content={post.attachments[0].url} /> <meta property="og:image" content={post.attachments[0].url} />
@ -36,7 +42,7 @@
<div class="error-container"> <div class="error-container">
<div class="error-content"> <div class="error-content">
<h1>Post Not Found</h1> <h1>Post Not Found</h1>
<p>{error || 'The post you\'re looking for doesn\'t exist.'}</p> <p>{error || "The post you're looking for doesn't exist."}</p>
<a href="/universe" class="back-link">← Back to Universe</a> <a href="/universe" class="back-link">← Back to Universe</a>
</div> </div>
</div> </div>

View file

@ -37,11 +37,18 @@
</Page> </Page>
{:else if project.status === 'password-protected'} {:else if project.status === 'password-protected'}
<Page> <Page>
<ProjectPasswordProtection projectSlug={project.slug} correctPassword={project.password || ''} projectType="work"> <ProjectPasswordProtection
projectSlug={project.slug}
correctPassword={project.password || ''}
projectType="work"
>
{#snippet children()} {#snippet children()}
<div slot="header" class="project-header"> <div slot="header" class="project-header">
{#if project.logoUrl} {#if project.logoUrl}
<div class="project-logo" style="background-color: {project.backgroundColor || '#f5f5f5'}"> <div
class="project-logo"
style="background-color: {project.backgroundColor || '#f5f5f5'}"
>
<img src={project.logoUrl} alt="{project.title} logo" /> <img src={project.logoUrl} alt="{project.title} logo" />
</div> </div>
{/if} {/if}

View file

@ -1,7 +1,7 @@
<script module> <script module>
import { defineMeta } from '@storybook/addon-svelte-csf'; import { defineMeta } from '@storybook/addon-svelte-csf'
import Button from './Button.svelte'; import Button from './Button.svelte'
import { fn } from 'storybook/test'; import { fn } from 'storybook/test'
// More on how to set up stories at: https://storybook.js.org/docs/writing-stories // More on how to set up stories at: https://storybook.js.org/docs/writing-stories
const { Story } = defineMeta({ const { Story } = defineMeta({
@ -12,13 +12,13 @@
backgroundColor: { control: 'color' }, backgroundColor: { control: 'color' },
size: { size: {
control: { type: 'select' }, control: { type: 'select' },
options: ['small', 'medium', 'large'], options: ['small', 'medium', 'large']
}, }
}, },
args: { args: {
onclick: fn(), onclick: fn()
} }
}); })
</script> </script>
<!-- More on writing stories with args: https://storybook.js.org/docs/writing-stories/args --> <!-- More on writing stories with args: https://storybook.js.org/docs/writing-stories/args -->

View file

@ -1,23 +1,23 @@
<script lang="ts"> <script lang="ts">
import './button.css'; import './button.css'
interface Props { interface Props {
/** Is this the principal call to action on the page? */ /** Is this the principal call to action on the page? */
primary?: boolean; primary?: boolean
/** What background color to use */ /** What background color to use */
backgroundColor?: string; backgroundColor?: string
/** How large should the button be? */ /** How large should the button be? */
size?: 'small' | 'medium' | 'large'; size?: 'small' | 'medium' | 'large'
/** Button contents */ /** Button contents */
label: string; label: string
/** The onclick event handler */ /** The onclick event handler */
onclick?: () => void; onclick?: () => void
} }
const { primary = false, backgroundColor, size = 'medium', label, ...props }: Props = $props(); const { primary = false, backgroundColor, size = 'medium', label, ...props }: Props = $props()
let mode = $derived(primary ? 'storybook-button--primary' : 'storybook-button--secondary'); let mode = $derived(primary ? 'storybook-button--primary' : 'storybook-button--secondary')
let style = $derived(backgroundColor ? `background-color: ${backgroundColor}` : ''); let style = $derived(backgroundColor ? `background-color: ${backgroundColor}` : '')
</script> </script>
<button <button

View file

@ -1,21 +1,22 @@
import { Meta } from "@storybook/addon-docs/blocks"; import { Meta } from '@storybook/addon-docs/blocks'
import Github from "./assets/github.svg"; import Github from './assets/github.svg'
import Discord from "./assets/discord.svg"; import Discord from './assets/discord.svg'
import Youtube from "./assets/youtube.svg"; import Youtube from './assets/youtube.svg'
import Tutorials from "./assets/tutorials.svg"; import Tutorials from './assets/tutorials.svg'
import Styling from "./assets/styling.png"; import Styling from './assets/styling.png'
import Context from "./assets/context.png"; import Context from './assets/context.png'
import Assets from "./assets/assets.png"; import Assets from './assets/assets.png'
import Docs from "./assets/docs.png"; import Docs from './assets/docs.png'
import Share from "./assets/share.png"; import Share from './assets/share.png'
import FigmaPlugin from "./assets/figma-plugin.png"; import FigmaPlugin from './assets/figma-plugin.png'
import Testing from "./assets/testing.png"; import Testing from './assets/testing.png'
import Accessibility from "./assets/accessibility.png"; import Accessibility from './assets/accessibility.png'
import Theming from "./assets/theming.png"; import Theming from './assets/theming.png'
import AddonLibrary from "./assets/addon-library.png"; import AddonLibrary from './assets/addon-library.png'
export const RightArrow = () => <svg export const RightArrow = () => (
<svg
viewBox="0 0 14 14" viewBox="0 0 14 14"
width="8px" width="8px"
height="14px" height="14px"
@ -27,9 +28,10 @@ export const RightArrow = () => <svg
fill: 'currentColor', fill: 'currentColor',
'path fill': 'currentColor' 'path fill': 'currentColor'
}} }}
> >
<path d="m11.1 7.35-5.5 5.5a.5.5 0 0 1-.7-.7L10.04 7 4.9 1.85a.5.5 0 1 1 .7-.7l5.5 5.5c.2.2.2.5 0 .7Z" /> <path d="m11.1 7.35-5.5 5.5a.5.5 0 0 1-.7-.7L10.04 7 4.9 1.85a.5.5 0 1 1 .7-.7l5.5 5.5c.2.2.2.5 0 .7Z" />
</svg> </svg>
)
<Meta title="Configure your project" /> <Meta title="Configure your project" />
@ -38,6 +40,7 @@ export const RightArrow = () => <svg
# Configure your project # Configure your project
Because Storybook works separately from your app, you'll need to configure it for your specific stack and setup. Below, explore guides for configuring Storybook with popular frameworks and tools. If you get stuck, learn how you can ask for help from our community. Because Storybook works separately from your app, you'll need to configure it for your specific stack and setup. Below, explore guides for configuring Storybook with popular frameworks and tools. If you get stuck, learn how you can ask for help from our community.
</div> </div>
<div className="sb-section"> <div className="sb-section">
<div className="sb-section-item"> <div className="sb-section-item">
@ -84,6 +87,7 @@ export const RightArrow = () => <svg
# Do more with Storybook # Do more with Storybook
Now that you know the basics, let's explore other parts of Storybook that will improve your experience. This list is just to get you started. You can customise Storybook in many ways to fit your needs. Now that you know the basics, let's explore other parts of Storybook that will improve your experience. This list is just to get you started. You can customise Storybook in many ways to fit your needs.
</div> </div>
<div className="sb-section"> <div className="sb-section">
@ -203,6 +207,7 @@ export const RightArrow = () => <svg
target="_blank" target="_blank"
>Discover tutorials<RightArrow /></a> >Discover tutorials<RightArrow /></a>
</div> </div>
</div> </div>
<style> <style>

View file

@ -1,7 +1,7 @@
<script module> <script module>
import { defineMeta } from '@storybook/addon-svelte-csf'; import { defineMeta } from '@storybook/addon-svelte-csf'
import Header from './Header.svelte'; import Header from './Header.svelte'
import { fn } from 'storybook/test'; import { fn } from 'storybook/test'
// More on how to set up stories at: https://storybook.js.org/docs/writing-stories // More on how to set up stories at: https://storybook.js.org/docs/writing-stories
const { Story } = defineMeta({ const { Story } = defineMeta({
@ -11,14 +11,14 @@
tags: ['autodocs'], tags: ['autodocs'],
parameters: { parameters: {
// More on how to position stories at: https://storybook.js.org/docs/configure/story-layout // More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
layout: 'fullscreen', layout: 'fullscreen'
}, },
args: { args: {
onLogin: fn(), onLogin: fn(),
onLogout: fn(), onLogout: fn(),
onCreateAccount: fn(), onCreateAccount: fn()
} }
}); })
</script> </script>
<Story name="Logged In" args={{ user: { name: 'Jane Doe' } }} /> <Story name="Logged In" args={{ user: { name: 'Jane Doe' } }} />

View file

@ -1,15 +1,15 @@
<script lang="ts"> <script lang="ts">
import './header.css'; import './header.css'
import Button from './Button.svelte'; import Button from './Button.svelte'
interface Props { interface Props {
user?: { name: string }; user?: { name: string }
onLogin?: () => void; onLogin?: () => void
onLogout?: () => void; onLogout?: () => void
onCreateAccount?: () => void; onCreateAccount?: () => void
} }
const { user, onLogin, onLogout, onCreateAccount }: Props = $props(); const { user, onLogin, onLogout, onCreateAccount }: Props = $props()
</script> </script>
<header> <header>

View file

@ -1,8 +1,8 @@
<script module> <script module>
import { defineMeta } from '@storybook/addon-svelte-csf'; import { defineMeta } from '@storybook/addon-svelte-csf'
import { expect, userEvent, waitFor, within } from 'storybook/test'; import { expect, userEvent, waitFor, within } from 'storybook/test'
import Page from './Page.svelte'; import Page from './Page.svelte'
import { fn } from 'storybook/test'; import { fn } from 'storybook/test'
// More on how to set up stories at: https://storybook.js.org/docs/writing-stories // More on how to set up stories at: https://storybook.js.org/docs/writing-stories
const { Story } = defineMeta({ const { Story } = defineMeta({
@ -10,20 +10,22 @@
component: Page, component: Page,
parameters: { parameters: {
// More on how to position stories at: https://storybook.js.org/docs/configure/story-layout // More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
layout: 'fullscreen', layout: 'fullscreen'
}, }
}); })
</script> </script>
<Story name="Logged In" play={async ({ canvasElement }) => { <Story
const canvas = within(canvasElement); name="Logged In"
const loginButton = canvas.getByRole('button', { name: /Log in/i }); play={async ({ canvasElement }) => {
await expect(loginButton).toBeInTheDocument(); const canvas = within(canvasElement)
await userEvent.click(loginButton); const loginButton = canvas.getByRole('button', { name: /Log in/i })
await waitFor(() => expect(loginButton).not.toBeInTheDocument()); await expect(loginButton).toBeInTheDocument()
await userEvent.click(loginButton)
await waitFor(() => expect(loginButton).not.toBeInTheDocument())
const logoutButton = canvas.getByRole('button', { name: /Log out/i }); const logoutButton = canvas.getByRole('button', { name: /Log out/i })
await expect(logoutButton).toBeInTheDocument(); await expect(logoutButton).toBeInTheDocument()
}} }}
/> />

View file

@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import './page.css'; import './page.css'
import Header from './Header.svelte'; import Header from './Header.svelte'
let user = $state<{ name: string }>(); let user = $state<{ name: string }>()
</script> </script>
<article> <article>

View file

@ -1,5 +1,5 @@
import Button from '$lib/components/admin/Button.svelte'; import Button from '$lib/components/admin/Button.svelte'
import ButtonShowcase from './ButtonShowcase.svelte'; import ButtonShowcase from './ButtonShowcase.svelte'
export default { export default {
title: 'Admin/Button', title: 'Admin/Button',
@ -38,7 +38,7 @@ export default {
}, },
onclick: { action: 'clicked' } onclick: { action: 'clicked' }
} }
}; }
// Interactive Playground (this will be the default story for the controls) // Interactive Playground (this will be the default story for the controls)
export const Playground = { export const Playground = {
@ -52,49 +52,49 @@ export const Playground = {
active: false, active: false,
disabled: false disabled: false
} }
}; }
// Primary story // Primary story
export const Primary = { export const Primary = {
args: { args: {
variant: 'primary' variant: 'primary'
} }
}; }
// Secondary story // Secondary story
export const Secondary = { export const Secondary = {
args: { args: {
variant: 'secondary' variant: 'secondary'
} }
}; }
// Danger story // Danger story
export const Danger = { export const Danger = {
args: { args: {
variant: 'danger' variant: 'danger'
} }
}; }
// Ghost story // Ghost story
export const Ghost = { export const Ghost = {
args: { args: {
variant: 'ghost' variant: 'ghost'
} }
}; }
// Text story // Text story
export const Text = { export const Text = {
args: { args: {
variant: 'text' variant: 'text'
} }
}; }
// Overlay story // Overlay story
export const Overlay = { export const Overlay = {
args: { args: {
variant: 'overlay' variant: 'overlay'
} }
}; }
// Loading State // Loading State
export const Loading = { export const Loading = {
@ -102,7 +102,7 @@ export const Loading = {
variant: 'primary', variant: 'primary',
loading: true loading: true
} }
}; }
// Disabled State // Disabled State
export const Disabled = { export const Disabled = {
@ -110,7 +110,7 @@ export const Disabled = {
variant: 'primary', variant: 'primary',
disabled: true disabled: true
} }
}; }
// Full Width // Full Width
export const FullWidth = { export const FullWidth = {
@ -118,11 +118,11 @@ export const FullWidth = {
variant: 'primary', variant: 'primary',
fullWidth: true fullWidth: true
} }
}; }
// All variants showcase // All variants showcase
export const AllVariants = { export const AllVariants = {
render: () => ({ render: () => ({
Component: ButtonShowcase Component: ButtonShowcase
}) })
}; }

View file

@ -1,5 +1,5 @@
<script> <script>
import Button from '$lib/components/admin/Button.svelte'; import Button from '$lib/components/admin/Button.svelte'
</script> </script>
<div class="button-showcase"> <div class="button-showcase">
@ -30,24 +30,77 @@
<h4>With Icons</h4> <h4>With Icons</h4>
<div class="button-grid"> <div class="button-grid">
<Button variant="primary" iconPosition="left"> <Button variant="primary" iconPosition="left">
<svg slot="icon" width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg
<path d="M12 5V19M5 12H19" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> slot="icon"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 5V19M5 12H19"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg> </svg>
Add Item Add Item
</Button> </Button>
<Button variant="secondary" iconPosition="right"> <Button variant="secondary" iconPosition="right">
Download Download
<svg slot="icon" width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg
<path d="M21 15V19A2 2 0 0119 21H5A2 2 0 013 19V15" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> slot="icon"
<polyline points="7,10 12,15 17,10" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> width="16"
<line x1="12" y1="15" x2="12" y2="3" stroke="currentColor" stroke-width="2" stroke-linecap="round"/> height="16"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M21 15V19A2 2 0 0119 21H5A2 2 0 013 19V15"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<polyline
points="7,10 12,15 17,10"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<line
x1="12"
y1="15"
x2="12"
y2="3"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
</svg> </svg>
</Button> </Button>
<Button variant="ghost" iconOnly={true}> <Button variant="ghost" iconOnly={true}>
<svg slot="icon" width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg
<path d="M6 6L18 18M6 18L18 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> slot="icon"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 6L18 18M6 18L18 6"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg> </svg>
</Button> </Button>
</div> </div>

View file

@ -1,4 +1,4 @@
import ImageUploader from '$lib/components/admin/ImageUploader.svelte'; import ImageUploader from '$lib/components/admin/ImageUploader.svelte'
// Mock Media object for testing // Mock Media object for testing
const mockMedia = { const mockMedia = {
@ -15,7 +15,7 @@ const mockMedia = {
description: 'This is a sample image for testing purposes', description: 'This is a sample image for testing purposes',
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date() updatedAt: new Date()
}; }
export default { export default {
title: 'Admin/Form Components/ImageUploader', title: 'Admin/Form Components/ImageUploader',
@ -51,7 +51,7 @@ export default {
control: 'text' control: 'text'
} }
} }
}; }
// Empty uploader // Empty uploader
export const Empty = { export const Empty = {
@ -62,7 +62,7 @@ export const Empty = {
required: false, required: false,
maxFileSize: 10 maxFileSize: 10
} }
}; }
// With uploaded image // With uploaded image
export const WithImage = { export const WithImage = {
@ -72,7 +72,7 @@ export const WithImage = {
allowAltText: true, allowAltText: true,
aspectRatio: '1:1' aspectRatio: '1:1'
} }
}; }
// Square aspect ratio // Square aspect ratio
export const SquareAspectRatio = { export const SquareAspectRatio = {
@ -83,7 +83,7 @@ export const SquareAspectRatio = {
allowAltText: true, allowAltText: true,
required: true required: true
} }
}; }
// Wide aspect ratio // Wide aspect ratio
export const WideAspectRatio = { export const WideAspectRatio = {
@ -94,7 +94,7 @@ export const WideAspectRatio = {
allowAltText: true, allowAltText: true,
helpText: 'Recommended size: 1920x1080 pixels' helpText: 'Recommended size: 1920x1080 pixels'
} }
}; }
// Required field // Required field
export const Required = { export const Required = {
@ -104,7 +104,7 @@ export const Required = {
allowAltText: true, allowAltText: true,
placeholder: 'This field is required' placeholder: 'This field is required'
} }
}; }
// With error // With error
export const WithError = { export const WithError = {
@ -113,7 +113,7 @@ export const WithError = {
error: 'Please select a valid image file', error: 'Please select a valid image file',
allowAltText: true allowAltText: true
} }
}; }
// With help text // With help text
export const WithHelpText = { export const WithHelpText = {
@ -124,7 +124,7 @@ export const WithHelpText = {
aspectRatio: '1:1', aspectRatio: '1:1',
maxFileSize: 5 maxFileSize: 5
} }
}; }
// Without alt text // Without alt text
export const WithoutAltText = { export const WithoutAltText = {
@ -134,7 +134,7 @@ export const WithoutAltText = {
placeholder: 'Upload a decorative image', placeholder: 'Upload a decorative image',
helpText: 'This image is purely decorative and does not need alt text.' helpText: 'This image is purely decorative and does not need alt text.'
} }
}; }
// With browse library option // With browse library option
export const WithBrowseLibrary = { export const WithBrowseLibrary = {
@ -144,7 +144,7 @@ export const WithBrowseLibrary = {
showBrowseLibrary: true, showBrowseLibrary: true,
placeholder: 'Upload a new image or browse existing ones' placeholder: 'Upload a new image or browse existing ones'
} }
}; }
// Small file size limit // Small file size limit
export const SmallFileLimit = { export const SmallFileLimit = {
@ -154,7 +154,7 @@ export const SmallFileLimit = {
allowAltText: true, allowAltText: true,
helpText: 'Maximum file size: 1MB' helpText: 'Maximum file size: 1MB'
} }
}; }
// Interactive playground // Interactive playground
export const Playground = { export const Playground = {
@ -166,4 +166,4 @@ export const Playground = {
maxFileSize: 10, maxFileSize: 10,
showBrowseLibrary: false showBrowseLibrary: false
} }
}; }

View file

@ -1,4 +1,4 @@
import Input from '$lib/components/admin/Input.svelte'; import Input from '$lib/components/admin/Input.svelte'
export default { export default {
title: 'Admin/Input', title: 'Admin/Input',
@ -7,7 +7,19 @@ export default {
argTypes: { argTypes: {
type: { type: {
control: { type: 'select' }, control: { type: 'select' },
options: ['text', 'email', 'password', 'url', 'search', 'number', 'tel', 'date', 'time', 'color', 'textarea'] options: [
'text',
'email',
'password',
'url',
'search',
'number',
'tel',
'date',
'time',
'color',
'textarea'
]
}, },
size: { size: {
control: { type: 'select' }, control: { type: 'select' },
@ -50,7 +62,7 @@ export default {
control: 'number' control: 'number'
} }
} }
}; }
// Interactive Playground // Interactive Playground
export const Playground = { export const Playground = {
@ -64,7 +76,7 @@ export const Playground = {
disabled: false, disabled: false,
readonly: false readonly: false
} }
}; }
// Basic text input // Basic text input
export const Basic = { export const Basic = {
@ -73,7 +85,7 @@ export const Basic = {
label: 'Basic Input', label: 'Basic Input',
placeholder: 'Type something...' placeholder: 'Type something...'
} }
}; }
// Email input // Email input
export const Email = { export const Email = {
@ -83,7 +95,7 @@ export const Email = {
placeholder: 'you@example.com', placeholder: 'you@example.com',
required: true required: true
} }
}; }
// Password input // Password input
export const Password = { export const Password = {
@ -93,7 +105,7 @@ export const Password = {
placeholder: 'Enter your password', placeholder: 'Enter your password',
required: true required: true
} }
}; }
// Search input // Search input
export const Search = { export const Search = {
@ -102,7 +114,7 @@ export const Search = {
label: 'Search', label: 'Search',
placeholder: 'Search for something...' placeholder: 'Search for something...'
} }
}; }
// Number input // Number input
export const Number = { export const Number = {
@ -113,7 +125,7 @@ export const Number = {
min: 0, min: 0,
max: 120 max: 120
} }
}; }
// Textarea // Textarea
export const Textarea = { export const Textarea = {
@ -123,7 +135,7 @@ export const Textarea = {
placeholder: 'Tell us about yourself...', placeholder: 'Tell us about yourself...',
rows: 4 rows: 4
} }
}; }
// Auto-resizing textarea // Auto-resizing textarea
export const AutoResizeTextarea = { export const AutoResizeTextarea = {
@ -134,7 +146,7 @@ export const AutoResizeTextarea = {
autoResize: true, autoResize: true,
rows: 2 rows: 2
} }
}; }
// With help text // With help text
export const WithHelpText = { export const WithHelpText = {
@ -144,7 +156,7 @@ export const WithHelpText = {
placeholder: 'you@example.com', placeholder: 'you@example.com',
helpText: 'We will never share your email with anyone else.' helpText: 'We will never share your email with anyone else.'
} }
}; }
// With error // With error
export const WithError = { export const WithError = {
@ -155,7 +167,7 @@ export const WithError = {
error: 'Please enter a valid email address.', error: 'Please enter a valid email address.',
required: true required: true
} }
}; }
// Character count // Character count
export const CharacterCount = { export const CharacterCount = {
@ -167,7 +179,7 @@ export const CharacterCount = {
showCharCount: true, showCharCount: true,
rows: 3 rows: 3
} }
}; }
// Different sizes // Different sizes
export const Sizes = { export const Sizes = {
@ -181,7 +193,7 @@ export const Sizes = {
`, `,
components: { Input } components: { Input }
}) })
}; }
// Disabled state // Disabled state
export const Disabled = { export const Disabled = {
@ -191,7 +203,7 @@ export const Disabled = {
value: 'This input is disabled', value: 'This input is disabled',
disabled: true disabled: true
} }
}; }
// Readonly state // Readonly state
export const Readonly = { export const Readonly = {
@ -201,7 +213,7 @@ export const Readonly = {
value: 'This input is readonly', value: 'This input is readonly',
readonly: true readonly: true
} }
}; }
// With prefix icon // With prefix icon
export const WithPrefixIcon = { export const WithPrefixIcon = {
@ -223,7 +235,7 @@ export const WithPrefixIcon = {
` `
} }
}) })
}; }
// With suffix icon // With suffix icon
export const WithSuffixIcon = { export const WithSuffixIcon = {
@ -245,7 +257,7 @@ export const WithSuffixIcon = {
` `
} }
}) })
}; }
// Color input // Color input
export const ColorInput = { export const ColorInput = {
@ -254,7 +266,7 @@ export const ColorInput = {
label: 'Pick a Color', label: 'Pick a Color',
value: '#ff6b6b' value: '#ff6b6b'
} }
}; }
// Date input // Date input
export const DateInput = { export const DateInput = {
@ -263,7 +275,7 @@ export const DateInput = {
label: 'Select Date', label: 'Select Date',
required: true required: true
} }
}; }
// Time input // Time input
export const TimeInput = { export const TimeInput = {
@ -271,4 +283,4 @@ export const TimeInput = {
type: 'time', type: 'time',
label: 'Select Time' label: 'Select Time'
} }
}; }

View file

@ -1,4 +1,4 @@
import MediaInput from '$lib/components/admin/MediaInput.svelte'; import MediaInput from '$lib/components/admin/MediaInput.svelte'
// Mock Media objects for testing // Mock Media objects for testing
const mockMedia = { const mockMedia = {
@ -13,7 +13,7 @@ const mockMedia = {
thumbnailUrl: 'https://via.placeholder.com/300x200/0066cc/ffffff?text=Sample+Image', thumbnailUrl: 'https://via.placeholder.com/300x200/0066cc/ffffff?text=Sample+Image',
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date() updatedAt: new Date()
}; }
const mockMediaList = [ const mockMediaList = [
mockMedia, mockMedia,
@ -43,7 +43,7 @@ const mockMediaList = [
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date() updatedAt: new Date()
} }
]; ]
export default { export default {
title: 'Admin/Form Components/MediaInput', title: 'Admin/Form Components/MediaInput',
@ -71,7 +71,7 @@ export default {
control: 'text' control: 'text'
} }
} }
}; }
// Single media input (empty) // Single media input (empty)
export const SingleEmpty = { export const SingleEmpty = {
@ -82,7 +82,7 @@ export const SingleEmpty = {
placeholder: 'No image selected', placeholder: 'No image selected',
value: null value: null
} }
}; }
// Single media input (with value) // Single media input (with value)
export const SingleWithValue = { export const SingleWithValue = {
@ -92,7 +92,7 @@ export const SingleWithValue = {
fileType: 'image', fileType: 'image',
value: mockMedia value: mockMedia
} }
}; }
// Multiple media input (empty) // Multiple media input (empty)
export const MultipleEmpty = { export const MultipleEmpty = {
@ -103,7 +103,7 @@ export const MultipleEmpty = {
placeholder: 'No images selected', placeholder: 'No images selected',
value: [] value: []
} }
}; }
// Multiple media input (with values) // Multiple media input (with values)
export const MultipleWithValues = { export const MultipleWithValues = {
@ -113,7 +113,7 @@ export const MultipleWithValues = {
fileType: 'image', fileType: 'image',
value: mockMediaList value: mockMediaList
} }
}; }
// Required field // Required field
export const Required = { export const Required = {
@ -124,7 +124,7 @@ export const Required = {
required: true, required: true,
placeholder: 'Choose a logo image' placeholder: 'Choose a logo image'
} }
}; }
// With error state // With error state
export const WithError = { export const WithError = {
@ -135,7 +135,7 @@ export const WithError = {
required: true, required: true,
error: 'Please select a profile picture' error: 'Please select a profile picture'
} }
}; }
// All file types // All file types
export const AllFileTypes = { export const AllFileTypes = {
@ -145,7 +145,7 @@ export const AllFileTypes = {
fileType: 'all', fileType: 'all',
placeholder: 'Choose any media file' placeholder: 'Choose any media file'
} }
}; }
// Video only // Video only
export const VideoOnly = { export const VideoOnly = {
@ -155,7 +155,7 @@ export const VideoOnly = {
fileType: 'video', fileType: 'video',
placeholder: 'Choose a video file' placeholder: 'Choose a video file'
} }
}; }
// Multiple with many files // Multiple with many files
export const MultipleWithManyFiles = { export const MultipleWithManyFiles = {
@ -193,7 +193,7 @@ export const MultipleWithManyFiles = {
} }
] ]
} }
}; }
// Interactive playground // Interactive playground
export const Playground = { export const Playground = {
@ -204,4 +204,4 @@ export const Playground = {
required: false, required: false,
placeholder: 'Select a file' placeholder: 'Select a file'
} }
}; }

View file

@ -1,34 +1,35 @@
// Simple test to check if project edit page loads correctly // Simple test to check if project edit page loads correctly
const puppeteer = require('puppeteer'); const puppeteer = require('puppeteer')
(async () => { ;(async () => {
const browser = await puppeteer.launch({ headless: false }); const browser = await puppeteer.launch({ headless: false })
const page = await browser.newPage(); const page = await browser.newPage()
try { try {
// Go to admin login first (might be needed) // Go to admin login first (might be needed)
await page.goto('http://localhost:5173/admin/login'); await page.goto('http://localhost:5173/admin/login')
await page.waitForTimeout(1000); await page.waitForTimeout(1000)
// Try to go directly to edit page // Try to go directly to edit page
await page.goto('http://localhost:5173/admin/projects/8/edit'); await page.goto('http://localhost:5173/admin/projects/8/edit')
await page.waitForTimeout(2000); await page.waitForTimeout(2000)
// Check if title field is populated // Check if title field is populated
const titleValue = await page.$eval('input[placeholder*="title" i], input[name*="title" i], #title', const titleValue = await page.$eval(
el => el.value); 'input[placeholder*="title" i], input[name*="title" i], #title',
(el) => el.value
)
console.log('Title field value:', titleValue); console.log('Title field value:', titleValue)
if (titleValue === 'Maitsu') { if (titleValue === 'Maitsu') {
console.log('✅ Form loading works correctly!'); console.log('✅ Form loading works correctly!')
} else { } else {
console.log('❌ Form loading failed - title not populated'); console.log('❌ Form loading failed - title not populated')
} }
} catch (error) { } catch (error) {
console.error('Test failed:', error.message); console.error('Test failed:', error.message)
} finally { } finally {
await browser.close(); await browser.close()
} }
})(); })()