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 { mergeConfig } from 'vite';
import path from 'path';
import type { StorybookConfig } from '@storybook/sveltekit'
import { mergeConfig } from 'vite'
import path from 'path'
const config: StorybookConfig = {
stories: [
"../src/**/*.mdx",
"../src/**/*.stories.@(js|ts|svelte)"
],
addons: [
"@storybook/addon-svelte-csf",
"@storybook/addon-docs",
"@storybook/addon-a11y"
],
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|ts|svelte)'],
addons: ['@storybook/addon-svelte-csf', '@storybook/addon-docs', '@storybook/addon-a11y'],
framework: {
name: "@storybook/sveltekit",
name: '@storybook/sveltekit',
options: {}
},
viteFinal: async (config) => {
return mergeConfig(config, {
resolve: {
alias: {
'$lib': path.resolve('./src/lib'),
'$components': path.resolve('./src/lib/components'),
'$icons': path.resolve('./src/assets/icons'),
'$illos': path.resolve('./src/assets/illos'),
'$styles': path.resolve('./src/assets/styles')
$lib: path.resolve('./src/lib'),
$components: path.resolve('./src/lib/components'),
$icons: path.resolve('./src/assets/icons'),
$illos: path.resolve('./src/assets/illos'),
$styles: path.resolve('./src/assets/styles')
}
},
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 '../src/assets/styles/reset.css';
import '../src/assets/styles/globals.scss';
import type { Preview } from '@storybook/sveltekit'
import '../src/assets/styles/reset.css'
import '../src/assets/styles/globals.scss'
const preview: Preview = {
parameters: {
@ -8,8 +8,8 @@ const preview: Preview = {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
date: /Date$/
}
},
backgrounds: {
default: 'light',
@ -17,8 +17,8 @@ const preview: Preview = {
{ name: 'light', value: '#ffffff' },
{ name: 'dark', value: '#333333' },
{ name: 'admin', value: '#f5f5f5' },
{ name: 'grey-95', value: '#f8f9fa' },
],
{ name: 'grey-95', value: '#f8f9fa' }
]
},
viewport: {
viewports: {
@ -33,10 +33,10 @@ const preview: Preview = {
desktop: {
name: 'Desktop',
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.
### Key Architecture Components
**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
import storybook from "eslint-plugin-storybook";
import storybook from 'eslint-plugin-storybook'
import js from '@eslint/js'
import ts from 'typescript-eslint'
@ -33,5 +33,5 @@ export default [
{
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',
title: 'granblue.team',
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,
client: 'Personal Project',
role: 'Full-Stack Developer',
@ -119,7 +120,8 @@ async function main() {
slug: 'subway-board',
title: 'Subway Board',
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,
client: 'Personal Project',
role: 'Developer & Designer',
@ -136,7 +138,8 @@ async function main() {
slug: 'siero-discord',
title: 'Siero for Discord',
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,
client: 'Personal Project',
role: 'Bot Developer',
@ -153,7 +156,8 @@ async function main() {
slug: 'homelab',
title: 'Homelab',
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,
client: 'Personal Project',
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: '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',
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: [
{
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: [
{
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',
@ -229,7 +237,8 @@ async function main() {
},
{
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: [
{
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 {
'Authorization': `Basic ${adminCredentials}`
Authorization: `Basic ${adminCredentials}`
}
}
@ -32,7 +32,10 @@ export function clearAuth() {
}
// 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 = {
...getAuthHeaders(),
...options.headers

View file

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

View file

@ -38,9 +38,18 @@
{#if project.status === 'password-protected'}
<div class="status-indicator password-protected">
<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"/>
<circle cx="12" cy="16" r="1" fill="currentColor"/>
<path d="M7 11V7a5 5 0 0 1 10 0v4" stroke="currentColor" stroke-width="2"/>
<rect
x="3"
y="11"
width="18"
height="11"
rx="2"
ry="2"
stroke="currentColor"
stroke-width="2"
/>
<circle cx="12" cy="16" r="1" fill="currentColor" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" stroke="currentColor" stroke-width="2" />
</svg>
<span>Password Protected</span>
</div>
@ -81,8 +90,20 @@
{#if project.status === 'list-only'}
<div class="status-indicator list-only">
<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 d="M1 1l22 22" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<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
d="M1 1l22 22"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
<span>View Only</span>
</div>

View file

@ -32,7 +32,7 @@
error = ''
// Simulate a small delay for better UX
await new Promise(resolve => setTimeout(resolve, 500))
await new Promise((resolve) => setTimeout(resolve, 500))
if (password === correctPassword) {
// Store in session storage
@ -63,7 +63,13 @@
{#snippet passwordHeader()}
<div class="password-header">
<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
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"
@ -191,7 +197,9 @@
border: 1px solid $grey-80;
border-radius: $unit;
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 {
outline: none;

View file

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

View file

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

View file

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

View file

@ -90,7 +90,7 @@
// Get thumbnail - try cover photo first, then first photo
function getThumbnailUrl(): string | null {
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) {
return coverPhoto.thumbnailUrl || coverPhoto.url
}

View file

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

View file

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

View file

@ -212,7 +212,9 @@
if (!clipboardData) return false
// 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) {
const file = imageItem.getAsFile()
if (!file) return false

View file

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

View file

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

View file

@ -135,10 +135,7 @@
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node
// Don't close if clicking inside the trigger button or the popover itself
if (
triggerElement?.contains(target) ||
popoverElement?.contains(target)
) {
if (triggerElement?.contains(target) || popoverElement?.contains(target)) {
return
}
onClose()
@ -221,7 +218,7 @@
label={field.label}
bind:value={data.tagInput}
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>
@ -459,7 +456,6 @@
}
}
@include breakpoint('phone') {
.metadata-popover {
min-width: 280px;

View file

@ -57,9 +57,7 @@
? 'aspect-ratio: 16/9;'
: (() => {
const [width, height] = aspectRatio.split(':').map(Number)
return width && height
? `aspect-ratio: ${width}/${height};`
: 'aspect-ratio: 16/9;'
return width && height ? `aspect-ratio: ${width}/${height};` : 'aspect-ratio: 16/9;'
})()
)
</script>
@ -82,16 +80,12 @@
tabindex="0"
onclick={openModal}
onkeydown={(e) => e.key === 'Enter' && openModal()}
onmouseenter={() => isHovering = true}
onmouseleave={() => isHovering = false}
onmouseenter={() => (isHovering = true)}
onmouseleave={() => (isHovering = false)}
>
{#if hasImage && value}
<!-- Image Display -->
<img
src={value.url}
alt={value.filename}
class="preview-image"
/>
<img src={value.url} alt={value.filename} class="preview-image" />
<!-- Hover Overlay -->
{#if isHovering}
@ -149,10 +143,24 @@
<!-- Empty State -->
<div class="empty-state">
<div class="empty-icon">
<svg width="48" height="48" 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
width="48"
height="48"
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>
</div>
<p class="empty-text">{placeholder}</p>

View file

@ -31,7 +31,8 @@
const selectedMedia = Array.isArray(media) ? media[0] : media
if (selectedMedia) {
// 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
.chain()
@ -273,8 +274,12 @@
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
:global(.edra-image-placeholder-icon) {

View file

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

View file

@ -13,12 +13,7 @@
onUpdate: (updatedMedia: Media) => void
}
let {
isOpen = $bindable(),
media,
onClose,
onUpdate
}: Props = $props()
let { isOpen = $bindable(), media, onClose, onUpdate }: Props = $props()
// Form state
let altText = $state('')
@ -29,14 +24,16 @@
let successMessage = $state('')
// Usage tracking state
let usage = $state<Array<{
let usage = $state<
Array<{
contentType: string
contentId: number
contentTitle: string
fieldDisplayName: string
contentUrl?: string
createdAt: string
}>>([])
}>
>([])
let loadingUsage = $state(false)
// Initialize form when media changes
@ -115,7 +112,6 @@
setTimeout(() => {
handleClose()
}, 1500)
} catch (err) {
error = 'Failed to update media. Please try again.'
console.error('Failed to update media:', err)
@ -125,7 +121,10 @@
}
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
}
@ -144,7 +143,6 @@
// Close modal and let parent handle the deletion
handleClose()
// Note: Parent component should refresh the media list
} catch (err) {
error = 'Failed to delete media. Please try again.'
console.error('Failed to delete media:', err)
@ -155,12 +153,15 @@
function copyUrl() {
if (media?.url) {
navigator.clipboard.writeText(media.url).then(() => {
navigator.clipboard
.writeText(media.url)
.then(() => {
successMessage = 'URL copied to clipboard!'
setTimeout(() => {
successMessage = ''
}, 2000)
}).catch(() => {
})
.catch(() => {
error = 'Failed to copy URL'
setTimeout(() => {
error = ''
@ -187,7 +188,13 @@
</script>
{#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">
<!-- Header -->
<div class="modal-header">
@ -197,8 +204,20 @@
</div>
{#if !isSaving}
<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">
<path d="M6 6L18 18M6 18L18 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
<svg
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>
</Button>
{/if}
@ -213,9 +232,27 @@
<SmartImage {media} alt={media.altText || media.filename} />
{:else}
<div class="file-placeholder">
<svg width="64" 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
width="64"
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>
<span class="file-type">{getFileType(media.mimeType)}</span>
</div>
@ -246,9 +283,7 @@
<span class="label">URL:</span>
<div class="url-section">
<span class="url-text">{media.url}</span>
<Button variant="ghost" buttonSize="small" onclick={copyUrl}>
Copy
</Button>
<Button variant="ghost" buttonSize="small" onclick={copyUrl}>Copy</Button>
</div>
</div>
</div>
@ -291,7 +326,8 @@
<span class="toggle-slider"></span>
<div class="toggle-content">
<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>
</label>
</div>
@ -311,7 +347,12 @@
<div class="usage-content">
<div class="usage-header">
{#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}
</a>
{:else}
@ -321,7 +362,9 @@
</div>
<div class="usage-details">
<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>
</li>
@ -337,12 +380,7 @@
<!-- Footer -->
<div class="modal-footer">
<div class="footer-left">
<Button
variant="ghost"
onclick={handleDelete}
disabled={isSaving}
class="delete-button"
>
<Button variant="ghost" onclick={handleDelete} disabled={isSaving} class="delete-button">
Delete
</Button>
</div>
@ -355,9 +393,7 @@
<span class="success-text">{successMessage}</span>
{/if}
<Button variant="ghost" onclick={handleClose} disabled={isSaving}>
Cancel
</Button>
<Button variant="ghost" onclick={handleClose} disabled={isSaving}>Cancel</Button>
<Button variant="primary" onclick={handleSave} disabled={isSaving}>
{isSaving ? 'Saving...' : 'Save Changes'}
</Button>
@ -711,8 +747,12 @@
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
// Responsive adjustments

View file

@ -75,17 +75,17 @@
: mode === 'single' && value && !Array.isArray(value)
? [value.id]
: mode === 'multiple' && Array.isArray(value)
? value.map(item => item.id)
? value.map((item) => item.id)
: []
)
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(
mode === 'single' ? 'Select' : 'Select Files'
)
const confirmText = $derived(mode === 'single' ? 'Select' : 'Select Files')
</script>
<div class="media-input">
@ -106,10 +106,29 @@
<img src={value.thumbnailUrl} alt={value.filename} />
{:else}
<div class="media-placeholder">
<svg width="24" height="24" 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
width="24"
height="24"
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>
</div>
{/if}
@ -133,10 +152,29 @@
<img src={item.thumbnailUrl} alt={item.filename} />
{:else}
<div class="media-placeholder">
<svg width="16" height="16" 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
width="16"
height="16"
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>
</div>
{/if}
@ -168,9 +206,7 @@
class:placeholder={!hasValue}
/>
<div class="input-actions">
<Button variant="ghost" onclick={openModal}>
Browse
</Button>
<Button variant="ghost" onclick={openModal}>Browse</Button>
{#if hasValue}
<Button variant="ghost" onclick={handleClear} aria-label="Clear selection">
<svg
@ -205,7 +241,7 @@
{fileType}
{selectedIds}
title={modalTitle}
confirmText={confirmText}
{confirmText}
onselect={handleMediaSelect}
/>
</div>

View file

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

View file

@ -12,12 +12,7 @@
loading?: boolean
}
let {
mode,
fileType = 'all',
selectedIds = [],
loading = $bindable(false)
}: Props = $props()
let { mode, fileType = 'all', selectedIds = [], loading = $bindable(false) }: Props = $props()
const dispatch = createEventDispatcher<{
select: Media[]
@ -37,7 +32,7 @@
// Initialize selected media from IDs
$effect(() => {
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)
}
})
@ -112,7 +107,6 @@
currentPage = page
totalPages = data.pagination.totalPages
total = data.pagination.total
} catch (error) {
console.error('Error loading media:', error)
} finally {
@ -125,10 +119,10 @@
selectedMedia = [item]
dispatch('select', selectedMedia)
} else {
const isSelected = selectedMedia.some(m => m.id === item.id)
const isSelected = selectedMedia.some((m) => m.id === item.id)
if (isSelected) {
selectedMedia = selectedMedia.filter(m => m.id !== item.id)
selectedMedia = selectedMedia.filter((m) => m.id !== item.id)
} else {
selectedMedia = [...selectedMedia, item]
}
@ -161,7 +155,7 @@
}
function isSelected(item: Media): boolean {
return selectedMedia.some(m => m.id === item.id)
return selectedMedia.some((m) => m.id === item.id)
}
// Computed properties
@ -174,11 +168,7 @@
<!-- Search and Filter Controls -->
<div class="controls">
<div class="search-filters">
<Input
type="search"
placeholder="Search media files..."
bind:value={searchQuery}
/>
<Input type="search" placeholder="Search media files..." bind:value={searchQuery} />
<select bind:value={filterType} class="filter-select">
<option value="all">All Files</option>
@ -216,10 +206,16 @@
</div>
{:else if media.length === 0}
<div class="empty-state">
<svg width="64" height="64" 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
width="64"
height="64"
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>
<h3>No media found</h3>
<p>Try adjusting your search or upload some files</p>
@ -237,16 +233,35 @@
<div class="media-thumbnail">
{#if item.mimeType?.startsWith('image/')}
<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}
loading="lazy"
/>
{:else}
<div class="media-placeholder">
<svg width="32" height="32" 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
width="32"
height="32"
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>
</div>
{/if}
@ -254,19 +269,27 @@
<!-- Selection Indicator -->
{#if mode === 'multiple'}
<div class="selection-checkbox">
<input
type="checkbox"
checked={isSelected(item)}
readonly
/>
<input type="checkbox" checked={isSelected(item)} readonly />
</div>
{/if}
<!-- Selected Overlay -->
{#if isSelected(item)}
<div class="selected-overlay">
<svg 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
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>
</div>
{/if}
@ -280,8 +303,17 @@
<div class="media-indicators">
{#if item.isPhotography}
<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">
<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
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>
Photo
</span>
@ -291,9 +323,7 @@
Alt
</span>
{:else}
<span class="indicator-pill no-alt-text" title="No alt text">
No Alt
</span>
<span class="indicator-pill no-alt-text" title="No alt text"> No Alt </span>
{/if}
</div>
<div class="media-meta">
@ -310,12 +340,7 @@
<!-- Load More Button -->
{#if hasMore}
<div class="load-more-container">
<Button
variant="ghost"
onclick={loadMore}
disabled={loading}
class="load-more-button"
>
<Button variant="ghost" onclick={loadMore} disabled={loading} class="load-more-button">
{#if loading}
<LoadingSpinner buttonSize="small" />
Loading...

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -13,12 +13,16 @@ declare module '@tiptap/core' {
/**
* 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>({
name: 'gallery',
@ -46,15 +50,16 @@ export const GalleryExtended = (component: Component<NodeViewProps>): Node<Galle
},
parseHTML() {
return [
{ tag: `div[data-type="${this.name}"]` }
]
return [{ tag: `div[data-type="${this.name}"]` }]
},
renderHTML({ HTMLAttributes }) {
return ['div', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
return [
'div',
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
'data-type': this.name
})]
})
]
},
group: 'block',
@ -67,7 +72,9 @@ export const GalleryExtended = (component: Component<NodeViewProps>): Node<Galle
addCommands() {
return {
setGallery: (options) => ({ commands }) => {
setGallery:
(options) =>
({ commands }) => {
return commands.insertContent({
type: this.name,
attrs: {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,7 @@
import { z } from 'zod'
export const projectSchema = z.object({
export const projectSchema = z
.object({
title: z.string().min(1, 'Title is required'),
description: z.string().optional(),
year: z
@ -21,7 +22,8 @@ export const projectSchema = z.object({
.or(z.literal('')),
status: z.enum(['draft', 'published', 'list-only', 'password-protected']),
password: z.string().optional()
}).refine(
})
.refine(
(data) => {
if (data.status === 'password-protected') {
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',
path: ['password']
}
)
)
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
export function getSmartImageUrl(
publicId: string,
containerWidth: number,
retina = true
): string {
export function getSmartImageUrl(publicId: string, containerWidth: number, retina = true): string {
// Account for retina displays
const targetWidth = retina ? containerWidth * 2 : containerWidth

View file

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

View file

@ -59,7 +59,9 @@ async function fetchRecentPSNGames(fetch: typeof window.fetch): Promise<Serializ
async function fetchProjects(
fetch: typeof window.fetch
): 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) {
throw new Error(`Failed to fetch projects: ${response.status}`)
}

View file

@ -238,7 +238,8 @@
{#if photographyFilter === 'all'}
No albums found. Create your first album!
{: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}
</p>
</div>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -65,8 +65,8 @@
prefixIcon
>
<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"/>
<path d="M11 11l3 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<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" />
</svg>
</Input>
@ -79,11 +79,7 @@
step={5}
/>
<Input
type="color"
label="Color Input"
bind:value={colorValue}
/>
<Input type="color" label="Color Input" bind:value={colorValue} />
</div>
</section>
@ -102,23 +98,11 @@
<section>
<h2>Input Sizes</h2>
<div class="input-group">
<Input
buttonSize="small"
label="Small Input"
placeholder="Small size"
/>
<Input buttonSize="small" label="Small Input" placeholder="Small size" />
<Input
buttonSize="medium"
label="Medium Input"
placeholder="Medium size (default)"
/>
<Input buttonSize="medium" label="Medium Input" placeholder="Medium size (default)" />
<Input
buttonSize="large"
label="Large Input"
placeholder="Large size"
/>
<Input buttonSize="large" label="Large Input" placeholder="Large size" />
</div>
</section>
@ -129,46 +113,49 @@
label="Input with Error"
placeholder="Try typing something"
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
label="Disabled Input"
bind:value={disabledValue}
disabled
/>
<Input label="Disabled Input" bind:value={disabledValue} disabled />
<Input
label="Readonly Input"
bind:value={readonlyValue}
readonly
/>
<Input label="Readonly Input" bind:value={readonlyValue} readonly />
</div>
</section>
<section>
<h2>Input with Icons</h2>
<div class="input-group">
<Input
label="With Prefix Icon"
placeholder="Username"
prefixIcon
>
<Input label="With Prefix Icon" placeholder="Username" prefixIcon>
<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"/>
<path d="M4 14c0-2.21 1.79-4 4-4s4 1.79 4 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<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"
/>
</svg>
</Input>
<Input
label="With Suffix Icon"
placeholder="Email"
type="email"
suffixIcon
>
<Input label="With Suffix Icon" placeholder="Email" type="email" suffixIcon>
<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"/>
<path d="M2 5l6 3 6-3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<rect
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>
</Input>
</div>
@ -198,11 +185,7 @@
<section>
<h2>Form Example</h2>
<form class="demo-form" on:submit|preventDefault>
<Input
label="Project Name"
placeholder="My Awesome Project"
required
/>
<Input label="Project Name" placeholder="My Awesome Project" required />
<Input
type="url"

View file

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

View file

@ -47,10 +47,13 @@
function addFiles(newFiles: File[]) {
// 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) {
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]
@ -98,7 +101,7 @@
const response = await fetch('/api/media/upload', {
method: 'POST',
headers: {
'Authorization': `Basic ${auth}`
Authorization: `Basic ${auth}`
},
body: formData
})
@ -156,12 +159,54 @@
<div class="drop-zone-content">
{#if files.length === 0}
<div class="upload-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" 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
width="48"
height="48"
viewBox="0 0 24 24"
fill="none"
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>
</div>
<h3>Drop images here</h3>
@ -200,7 +245,12 @@
<div class="file-list-header">
<h3>Files to Upload</h3>
<div class="file-actions">
<Button variant="secondary" buttonSize="small" onclick={clearAll} disabled={isUploading}>
<Button
variant="secondary"
buttonSize="small"
onclick={clearAll}
disabled={isUploading}
>
Clear All
</Button>
<Button
@ -248,7 +298,14 @@
onclick={() => removeFile(index)}
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="6" y1="6" x2="18" y2="18"></line>
</svg>
@ -267,7 +324,7 @@
<div class="success-message">
✅ Successfully uploaded {successCount} file{successCount !== 1 ? 's' : ''}
{#if successCount === files.length && uploadErrors.length === 0}
<br><small>Redirecting to media library...</small>
<br /><small>Redirecting to media library...</small>
{/if}
</div>
{/if}

View file

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

View file

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

View file

@ -233,7 +233,8 @@
{#if selectedStatusFilter === 'all' && selectedTypeFilter === 'all'}
No projects found. Create your first project!
{: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}
</p>
</div>
@ -263,8 +264,6 @@
/>
<style lang="scss">
.error {
text-align: center;
padding: $unit-6x;

View file

@ -1,6 +1,11 @@
import type { RequestHandler } from './$types'
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'
// 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
const mediaMap = new Map()
mediaUsages.forEach(usage => {
mediaUsages.forEach((usage) => {
if (usage.media) {
mediaMap.set(usage.mediaId, usage.media)
}
})
// 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
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
return {

View file

@ -1,6 +1,11 @@
import type { RequestHandler } from './$types'
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'
// POST /api/albums/[id]/photos - Add a photo to an album

View file

@ -1,7 +1,12 @@
import type { RequestHandler } from './$types'
import { prisma } from '$lib/server/database'
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'
// GET /api/media/[id] - Get a single media item

View file

@ -57,7 +57,6 @@ export const PATCH: RequestHandler = async (event) => {
description: updatedMedia.description,
updatedAt: updatedMedia.updatedAt
})
} catch (error) {
logger.error('Media metadata update error', error as Error)
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 { jsonResponse, errorResponse, checkAdminAuth } from '$lib/server/api-utils'
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
export const POST: RequestHandler = async (event) => {
@ -32,7 +37,7 @@ export const POST: RequestHandler = async (event) => {
for (const project of projects) {
// Track featured image
const featuredImageIds = extractMediaIds(project, 'featuredImage')
featuredImageIds.forEach(mediaId => {
featuredImageIds.forEach((mediaId) => {
usageReferences.push({
mediaId,
contentType: 'project',
@ -43,7 +48,7 @@ export const POST: RequestHandler = async (event) => {
// Track logo
const logoIds = extractMediaIds(project, 'logoUrl')
logoIds.forEach(mediaId => {
logoIds.forEach((mediaId) => {
usageReferences.push({
mediaId,
contentType: 'project',
@ -54,7 +59,7 @@ export const POST: RequestHandler = async (event) => {
// Track gallery images
const galleryIds = extractMediaIds(project, 'gallery')
galleryIds.forEach(mediaId => {
galleryIds.forEach((mediaId) => {
usageReferences.push({
mediaId,
contentType: 'project',
@ -65,7 +70,7 @@ export const POST: RequestHandler = async (event) => {
// Track media in case study content
const contentIds = extractMediaIds(project, 'caseStudyContent')
contentIds.forEach(mediaId => {
contentIds.forEach((mediaId) => {
usageReferences.push({
mediaId,
contentType: 'project',
@ -88,7 +93,7 @@ export const POST: RequestHandler = async (event) => {
for (const post of posts) {
// Track featured image
const featuredImageIds = extractMediaIds(post, 'featuredImage')
featuredImageIds.forEach(mediaId => {
featuredImageIds.forEach((mediaId) => {
usageReferences.push({
mediaId,
contentType: 'post',
@ -99,7 +104,7 @@ export const POST: RequestHandler = async (event) => {
// Track attachments
const attachmentIds = extractMediaIds(post, 'attachments')
attachmentIds.forEach(mediaId => {
attachmentIds.forEach((mediaId) => {
usageReferences.push({
mediaId,
contentType: 'post',
@ -110,7 +115,7 @@ export const POST: RequestHandler = async (event) => {
// Track media in post content
const contentIds = extractMediaIds(post, 'content')
contentIds.forEach(mediaId => {
contentIds.forEach((mediaId) => {
usageReferences.push({
mediaId,
contentType: 'post',

View file

@ -1,6 +1,11 @@
import type { RequestHandler } from './$types'
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 { 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)
}
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) {
return errorResponse('No valid media IDs provided', 400)
}
@ -50,16 +55,15 @@ export const DELETE: RequestHandler = async (event) => {
logger.info('Bulk media deletion completed', {
deletedCount: deleteResult.count,
mediaIds,
filenames: mediaRecords.map(m => m.filename)
filenames: mediaRecords.map((m) => m.filename)
})
return jsonResponse({
success: true,
message: `Successfully deleted ${deleteResult.count} media file${deleteResult.count > 1 ? 's' : ''}`,
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) {
logger.error('Failed to bulk delete media files', error as Error)
return errorResponse('Failed to delete media files', 500)
@ -74,7 +78,7 @@ async function cleanupMediaReferences(mediaIds: number[]) {
where: { id: { in: mediaIds } },
select: { url: true }
})
const urlsToRemove = mediaUrls.map(m => m.url)
const urlsToRemove = mediaUrls.map((m) => m.url)
// Clean up projects
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
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) {
return null // Mark for removal
}
@ -203,9 +207,7 @@ function cleanContentFromMedia(content: any, mediaIds: number[], urlsToRemove: s
// Clean gallery nodes
if (node.type === 'gallery' && node.attrs?.images) {
const filteredImages = node.attrs.images.filter((image: any) =>
!mediaIds.includes(image.id)
)
const filteredImages = node.attrs.images.filter((image: any) => !mediaIds.includes(image.id))
if (filteredImages.length === 0) {
return null // Remove empty gallery
@ -222,9 +224,7 @@ function cleanContentFromMedia(content: any, mediaIds: number[], urlsToRemove: s
// Recursively clean child nodes
if (node.content) {
const cleanedContent = node.content
.map(cleanNode)
.filter((child: any) => child !== null)
const cleanedContent = node.content.map(cleanNode).filter((child: any) => child !== null)
return {
...node,

View file

@ -12,9 +12,21 @@ async function extractExifData(file: File): Promise<any> {
const buffer = await file.arrayBuffer()
const exif = await exifr.parse(buffer, {
pick: [
'Make', 'Model', 'LensModel', 'FocalLength', 'FNumber', 'ExposureTime',
'ISO', 'DateTime', 'DateTimeOriginal', 'CreateDate', 'GPSLatitude',
'GPSLongitude', 'GPSAltitude', 'Orientation', 'ColorSpace'
'Make',
'Model',
'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
} 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
}
}
@ -183,7 +197,10 @@ export const POST: RequestHandler = async (event) => {
} catch (error) {
logger.error('Media upload error', error as 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
const photoAlbums: PhotoAlbum[] = albums
.filter(album => album.photos.length > 0) // Only include albums with published photos
.map(album => ({
.filter((album) => album.photos.length > 0) // Only include albums with published photos
.map((album) => ({
id: `album-${album.id}`,
slug: album.slug, // Add slug for navigation
title: album.title,
@ -80,7 +80,7 @@ export const GET: RequestHandler = async (event) => {
width: album.photos[0].width || 400,
height: album.photos[0].height || 400
},
photos: album.photos.map(photo => ({
photos: album.photos.map((photo) => ({
id: `photo-${photo.id}`,
src: photo.url,
alt: photo.caption || photo.filename,
@ -92,7 +92,7 @@ export const GET: RequestHandler = async (event) => {
}))
// Transform individual photos to Photo format
const photos: Photo[] = individualPhotos.map(photo => ({
const photos: Photo[] = individualPhotos.map((photo) => ({
id: `photo-${photo.id}`,
src: photo.url,
alt: photo.title || photo.caption || photo.filename,

View file

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

View file

@ -8,7 +8,11 @@ import {
checkAdminAuth
} from '$lib/server/api-utils'
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
export const GET: RequestHandler = async (event) => {
@ -126,7 +130,8 @@ export const POST: RequestHandler = async (event) => {
linkUrl: data.link_url,
linkDescription: data.linkDescription,
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,
publishedAt: data.publishedAt
}
@ -138,7 +143,7 @@ export const POST: RequestHandler = async (event) => {
// Track featured image
const featuredImageIds = extractMediaIds({ featuredImage: featuredImageId }, 'featuredImage')
featuredImageIds.forEach(mediaId => {
featuredImageIds.forEach((mediaId) => {
usageReferences.push({
mediaId,
contentType: 'post',
@ -173,7 +178,7 @@ export const POST: RequestHandler = async (event) => {
// Track media in post content
const contentIds = extractMediaIds({ content: postContent }, 'content')
contentIds.forEach(mediaId => {
contentIds.forEach((mediaId) => {
usageReferences.push({
mediaId,
contentType: 'post',

View file

@ -2,7 +2,13 @@ import type { RequestHandler } from './$types'
import { prisma } from '$lib/server/database'
import { jsonResponse, errorResponse, checkAdminAuth } from '$lib/server/api-utils'
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
export const GET: RequestHandler = async (event) => {
@ -93,7 +99,8 @@ export const PUT: RequestHandler = async (event) => {
linkUrl: data.link_url,
linkDescription: data.linkDescription,
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,
publishedAt: data.publishedAt
}
@ -109,7 +116,7 @@ export const PUT: RequestHandler = async (event) => {
// Track featured image
const featuredImageIds = extractMediaIds(post, 'featuredImage')
featuredImageIds.forEach(mediaId => {
featuredImageIds.forEach((mediaId) => {
usageReferences.push({
mediaId,
contentType: 'post',
@ -120,7 +127,7 @@ export const PUT: RequestHandler = async (event) => {
// Track attachments
const attachmentIds = extractMediaIds(post, 'attachments')
attachmentIds.forEach(mediaId => {
attachmentIds.forEach((mediaId) => {
usageReferences.push({
mediaId,
contentType: 'post',
@ -131,7 +138,7 @@ export const PUT: RequestHandler = async (event) => {
// Track media in post content
const contentIds = extractMediaIds(post, 'content')
contentIds.forEach(mediaId => {
contentIds.forEach((mediaId) => {
usageReferences.push({
mediaId,
contentType: 'post',

View file

@ -10,7 +10,11 @@ import {
} from '$lib/server/api-utils'
import { logger } from '$lib/server/logger'
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
export const GET: RequestHandler = async (event) => {
@ -22,7 +26,8 @@ export const GET: RequestHandler = async (event) => {
const status = event.url.searchParams.get('status')
const projectType = event.url.searchParams.get('projectType')
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
const where: any = {}
@ -126,7 +131,7 @@ export const POST: RequestHandler = async (event) => {
// Track featured image
const featuredImageIds = extractMediaIds(body, 'featuredImage')
featuredImageIds.forEach(mediaId => {
featuredImageIds.forEach((mediaId) => {
usageReferences.push({
mediaId,
contentType: 'project',
@ -137,7 +142,7 @@ export const POST: RequestHandler = async (event) => {
// Track logo
const logoIds = extractMediaIds(body, 'logoUrl')
logoIds.forEach(mediaId => {
logoIds.forEach((mediaId) => {
usageReferences.push({
mediaId,
contentType: 'project',
@ -148,7 +153,7 @@ export const POST: RequestHandler = async (event) => {
// Track gallery images
const galleryIds = extractMediaIds(body, 'gallery')
galleryIds.forEach(mediaId => {
galleryIds.forEach((mediaId) => {
usageReferences.push({
mediaId,
contentType: 'project',
@ -159,7 +164,7 @@ export const POST: RequestHandler = async (event) => {
// Track media in case study content
const contentIds = extractMediaIds(body, 'caseStudyContent')
contentIds.forEach(mediaId => {
contentIds.forEach((mediaId) => {
usageReferences.push({
mediaId,
contentType: 'project',

View file

@ -8,7 +8,12 @@ import {
} from '$lib/server/api-utils'
import { logger } from '$lib/server/logger'
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
export const GET: RequestHandler = async (event) => {
@ -103,7 +108,7 @@ export const PUT: RequestHandler = async (event) => {
// Track featured image
const featuredImageIds = extractMediaIds(project, 'featuredImage')
featuredImageIds.forEach(mediaId => {
featuredImageIds.forEach((mediaId) => {
usageReferences.push({
mediaId,
contentType: 'project',
@ -114,7 +119,7 @@ export const PUT: RequestHandler = async (event) => {
// Track logo
const logoIds = extractMediaIds(project, 'logoUrl')
logoIds.forEach(mediaId => {
logoIds.forEach((mediaId) => {
usageReferences.push({
mediaId,
contentType: 'project',
@ -125,7 +130,7 @@ export const PUT: RequestHandler = async (event) => {
// Track gallery images
const galleryIds = extractMediaIds(project, 'gallery')
galleryIds.forEach(mediaId => {
galleryIds.forEach((mediaId) => {
usageReferences.push({
mediaId,
contentType: 'project',
@ -136,7 +141,7 @@ export const PUT: RequestHandler = async (event) => {
// Track media in case study content
const contentIds = extractMediaIds(project, 'caseStudyContent')
contentIds.forEach(mediaId => {
contentIds.forEach((mediaId) => {
usageReferences.push({
mediaId,
contentType: 'project',

View file

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

View file

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

View file

@ -2,7 +2,9 @@ import type { PageLoad } from './$types'
export const load: PageLoad = async ({ fetch }) => {
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) {
throw new Error('Failed to fetch labs projects')
}

View file

@ -37,11 +37,18 @@
</Page>
{:else if project.status === 'password-protected'}
<Page>
<ProjectPasswordProtection projectSlug={project.slug} correctPassword={project.password || ''} projectType="labs">
<ProjectPasswordProtection
projectSlug={project.slug}
correctPassword={project.password || ''}
projectType="labs"
>
{#snippet children()}
<div slot="header" class="project-header">
{#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" />
</div>
{/if}

View file

@ -4,7 +4,9 @@ import type { Project } from '$lib/types/project'
export const load: PageLoad = async ({ params, fetch }) => {
try {
// 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) {
throw new Error('Failed to fetch projects')
}

View file

@ -34,7 +34,9 @@
exifData.aperture,
formatSpeed(exifData.shutterSpeed),
exifData.iso ? `ISO ${exifData.iso}` : null
].filter(Boolean).join(' • '),
]
.filter(Boolean)
.join(' • '),
location: exifData.location,
dateTaken: exifData.dateTaken
}
@ -45,12 +47,25 @@
<svelte:head>
{#if photo && album}
<title>{photo.title || photo.caption || `Photo ${navigation?.currentIndex}`} - {album.title}</title>
<meta name="description" content={photo.description || photo.caption || `Photo from ${album.title}`} />
<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 -->
<meta 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: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:image" content={photo.url} />
@ -68,7 +83,7 @@
<div class="error-container">
<div class="error-content">
<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>
</div>
</div>
@ -88,14 +103,26 @@
{#if navigation.prevPhoto}
<a href="/photos/{album.slug}/{navigation.prevPhoto.id}" class="nav-btn prev">
<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>
Previous
</a>
{:else}
<div class="nav-btn disabled">
<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>
Previous
</div>
@ -105,14 +132,26 @@
<a href="/photos/{album.slug}/{navigation.nextPhoto.id}" class="nav-btn next">
Next
<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>
</a>
{:else}
<div class="nav-btn disabled">
Next
<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>
</div>
{/if}
@ -241,16 +280,16 @@
min-height: 100vh;
display: grid;
grid-template-areas:
"header header"
"main details";
'header header'
'main details';
grid-template-columns: 1fr 400px;
grid-template-rows: auto 1fr;
@include breakpoint('tablet') {
grid-template-areas:
"header"
"main"
"details";
'header'
'main'
'details';
grid-template-columns: 1fr;
grid-template-rows: auto 1fr auto;
}

View file

@ -8,14 +8,16 @@
const error = $derived(data.error)
// 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}`,
src: photo.url,
alt: photo.caption || photo.filename,
caption: photo.caption,
width: photo.width || 400,
height: photo.height || 400
})) ?? [])
})) ?? []
)
const formatDate = (dateString: string) => {
const date = new Date(dateString)
@ -68,13 +70,15 @@
{#if album.location}
<span class="meta-item">📍 {album.location}</span>
{/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>
<!-- Photo Grid -->
{#if photoItems.length > 0}
<PhotoGrid photoItems={photoItems} albumSlug={album.slug} />
<PhotoGrid {photoItems} albumSlug={album.slug} />
{:else}
<div class="empty-album">
<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
return `<h${level}>${escapeXML(block.content || '')}</h${level}>`
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>`
default:
return `<p>${escapeXML(block.content || '')}</p>`
@ -119,11 +121,12 @@ export const GET: RequestHandler = async (event) => {
// Combine all content types
const items = [
...posts.map(post => ({
...posts.map((post) => ({
type: 'post',
section: 'universe',
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) || '',
content: convertContentToHTML(post.content),
link: `${event.url.origin}/universe/${post.slug}`,
@ -133,12 +136,14 @@ export const GET: RequestHandler = async (event) => {
postType: post.postType,
linkUrl: post.linkUrl || null
})),
...universeAlbums.map(album => ({
...universeAlbums.map((album) => ({
type: 'album',
section: 'universe',
id: album.id.toString(),
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>` : '',
link: `${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
})),
...photoAlbums
.filter(album => !universeAlbums.some(ua => ua.id === album.id)) // Avoid duplicates
.map(album => ({
.filter((album) => !universeAlbums.some((ua) => ua.id === album.id)) // Avoid duplicates
.map((album) => ({
type: 'album',
section: 'photos',
id: album.id.toString(),
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>` : '',
link: `${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>
<docs>https://cyber.harvard.edu/rss/rss.html</docs>
<ttl>60</ttl>
${items.map(item => `
${items
.map(
(item) => `
<item>
<title>${escapeXML(item.title)}</title>
<description><![CDATA[${item.description}]]></description>
@ -198,13 +207,19 @@ ${item.updatedDate ? `<atom:updated>${new Date(item.updatedDate).toISOString()}<
<category>${item.section}</category>
<category>${item.type === 'post' ? item.postType : 'album'}</category>
${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"/>
<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>` : ''}
<author>noreply@jedmund.com (Justin Edmund)</author>
</item>`).join('')}
</item>`
)
.join('')}
</channel>
</rss>`
@ -215,9 +230,9 @@ ${item.location ? `<category domain="location">${escapeXML(item.location)}</cate
'Content-Type': 'application/rss+xml; charset=utf-8',
'Cache-Control': 'public, max-age=3600, stale-while-revalidate=86400',
'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',
'Vary': 'Accept-Encoding'
Vary: 'Accept-Encoding'
}
})
} catch (error) {

View file

@ -63,11 +63,13 @@ export const GET: RequestHandler = async (event) => {
// Combine albums and standalone photos
const items = [
...albums.map(album => ({
...albums.map((album) => ({
type: 'album',
id: album.id.toString(),
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>` : '',
link: `${event.url.origin}/photos/${album.slug}`,
pubDate: album.createdAt,
@ -78,12 +80,16 @@ export const GET: RequestHandler = async (event) => {
location: album.location,
date: album.date
})),
...standalonePhotos.map(photo => ({
...standalonePhotos.map((photo) => ({
type: 'photo',
id: photo.id.toString(),
title: photo.title || 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}`,
pubDate: photo.publishedAt || photo.createdAt,
updatedDate: photo.updatedAt,
@ -111,7 +117,9 @@ export const GET: RequestHandler = async (event) => {
<generator>SvelteKit RSS Generator</generator>
<docs>https://cyber.harvard.edu/rss/rss.html</docs>
<ttl>60</ttl>
${items.map(item => `
${items
.map(
(item) => `
<item>
<title>${escapeXML(item.title)}</title>
<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>
${item.updatedDate ? `<atom:updated>${new Date(item.updatedDate).toISOString()}</atom:updated>` : ''}
<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"/>
<media:thumbnail url="${event.url.origin}${item.coverPhoto.thumbnailUrl || item.coverPhoto.url}"/>
<media:content url="${event.url.origin}${item.coverPhoto.url}" type="image/jpeg"/>` : ''}
${item.type === 'photo' ? `
<media:content url="${event.url.origin}${item.coverPhoto.url}" type="image/jpeg"/>`
: ''
}
${
item.type === 'photo'
? `
<enclosure url="${event.url.origin}${item.url}" type="image/jpeg" length="0"/>
<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>` : ''}
<author>noreply@jedmund.com (Justin Edmund)</author>
</item>`).join('')}
</item>`
)
.join('')}
</channel>
</rss>`
@ -142,9 +160,9 @@ ${item.location ? `<category domain="location">${escapeXML(item.location)}</cate
'Content-Type': 'application/rss+xml; charset=utf-8',
'Cache-Control': 'public, max-age=3600, stale-while-revalidate=86400',
'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',
'Vary': 'Accept-Encoding'
Vary: 'Accept-Encoding'
}
})
} catch (error) {

View file

@ -26,7 +26,9 @@ function convertContentToHTML(content: any): string {
const level = block.level || 2
return `<h${level}>${escapeXML(block.content || '')}</h${level}>`
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>`
default:
return `<p>${escapeXML(block.content || '')}</p>`
@ -81,10 +83,11 @@ export const GET: RequestHandler = async (event) => {
// Combine and sort by date
const items = [
...posts.map(post => ({
...posts.map((post) => ({
type: 'post',
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) || '',
content: convertContentToHTML(post.content),
link: `${event.url.origin}/universe/${post.slug}`,
@ -94,11 +97,13 @@ export const GET: RequestHandler = async (event) => {
postType: post.postType,
linkUrl: post.linkUrl || null
})),
...albums.map(album => ({
...albums.map((album) => ({
type: 'album',
id: album.id.toString(),
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>` : '',
link: `${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>
<docs>https://cyber.harvard.edu/rss/rss.html</docs>
<ttl>60</ttl>
${items.map(item => `
${items
.map(
(item) => `
<item>
<title>${escapeXML(item.title)}</title>
<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>
${item.type === 'post' && item.linkUrl ? `<comments>${item.linkUrl}</comments>` : ''}
<author>noreply@jedmund.com (Justin Edmund)</author>
</item>`).join('')}
</item>`
)
.join('')}
</channel>
</rss>`
@ -149,9 +158,9 @@ ${item.type === 'post' && item.linkUrl ? `<comments>${item.linkUrl}</comments>`
'Content-Type': 'application/rss+xml; charset=utf-8',
'Cache-Control': 'public, max-age=3600, stale-while-revalidate=86400',
'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',
'Vary': 'Accept-Encoding'
Vary: 'Accept-Encoding'
}
})
} catch (error) {

View file

@ -13,11 +13,17 @@
<svelte:head>
{#if post}
<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 -->
<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" />
{#if post.attachments && post.attachments.length > 0}
<meta property="og:image" content={post.attachments[0].url} />
@ -36,7 +42,7 @@
<div class="error-container">
<div class="error-content">
<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>
</div>
</div>

View file

@ -37,11 +37,18 @@
</Page>
{:else if project.status === 'password-protected'}
<Page>
<ProjectPasswordProtection projectSlug={project.slug} correctPassword={project.password || ''} projectType="work">
<ProjectPasswordProtection
projectSlug={project.slug}
correctPassword={project.password || ''}
projectType="work"
>
{#snippet children()}
<div slot="header" class="project-header">
{#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" />
</div>
{/if}

View file

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

View file

@ -1,23 +1,23 @@
<script lang="ts">
import './button.css';
import './button.css'
interface Props {
/** Is this the principal call to action on the page? */
primary?: boolean;
primary?: boolean
/** What background color to use */
backgroundColor?: string;
backgroundColor?: string
/** How large should the button be? */
size?: 'small' | 'medium' | 'large';
size?: 'small' | 'medium' | 'large'
/** Button contents */
label: string;
label: string
/** 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 style = $derived(backgroundColor ? `background-color: ${backgroundColor}` : '');
let mode = $derived(primary ? 'storybook-button--primary' : 'storybook-button--secondary')
let style = $derived(backgroundColor ? `background-color: ${backgroundColor}` : '')
</script>
<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 Discord from "./assets/discord.svg";
import Youtube from "./assets/youtube.svg";
import Tutorials from "./assets/tutorials.svg";
import Styling from "./assets/styling.png";
import Context from "./assets/context.png";
import Assets from "./assets/assets.png";
import Docs from "./assets/docs.png";
import Share from "./assets/share.png";
import FigmaPlugin from "./assets/figma-plugin.png";
import Testing from "./assets/testing.png";
import Accessibility from "./assets/accessibility.png";
import Theming from "./assets/theming.png";
import AddonLibrary from "./assets/addon-library.png";
import Github from './assets/github.svg'
import Discord from './assets/discord.svg'
import Youtube from './assets/youtube.svg'
import Tutorials from './assets/tutorials.svg'
import Styling from './assets/styling.png'
import Context from './assets/context.png'
import Assets from './assets/assets.png'
import Docs from './assets/docs.png'
import Share from './assets/share.png'
import FigmaPlugin from './assets/figma-plugin.png'
import Testing from './assets/testing.png'
import Accessibility from './assets/accessibility.png'
import Theming from './assets/theming.png'
import AddonLibrary from './assets/addon-library.png'
export const RightArrow = () => <svg
export const RightArrow = () => (
<svg
viewBox="0 0 14 14"
width="8px"
height="14px"
@ -27,9 +28,10 @@ export const RightArrow = () => <svg
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" />
</svg>
</svg>
)
<Meta title="Configure your project" />
@ -38,6 +40,7 @@ export const RightArrow = () => <svg
# 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.
</div>
<div className="sb-section">
<div className="sb-section-item">
@ -84,6 +87,7 @@ export const RightArrow = () => <svg
# 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.
</div>
<div className="sb-section">
@ -203,6 +207,7 @@ export const RightArrow = () => <svg
target="_blank"
>Discover tutorials<RightArrow /></a>
</div>
</div>
<style>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,5 @@
<script>
import Button from '$lib/components/admin/Button.svelte';
import Button from '$lib/components/admin/Button.svelte'
</script>
<div class="button-showcase">
@ -30,24 +30,77 @@
<h4>With Icons</h4>
<div class="button-grid">
<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">
<path d="M12 5V19M5 12H19" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<svg
slot="icon"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 5V19M5 12H19"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
Add Item
</Button>
<Button variant="secondary" iconPosition="right">
Download
<svg slot="icon" width="16" 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
slot="icon"
width="16"
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>
</Button>
<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">
<path d="M6 6L18 18M6 18L18 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<svg
slot="icon"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 6L18 18M6 18L18 6"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</Button>
</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
const mockMedia = {
@ -15,7 +15,7 @@ const mockMedia = {
description: 'This is a sample image for testing purposes',
createdAt: new Date(),
updatedAt: new Date()
};
}
export default {
title: 'Admin/Form Components/ImageUploader',
@ -51,7 +51,7 @@ export default {
control: 'text'
}
}
};
}
// Empty uploader
export const Empty = {
@ -62,7 +62,7 @@ export const Empty = {
required: false,
maxFileSize: 10
}
};
}
// With uploaded image
export const WithImage = {
@ -72,7 +72,7 @@ export const WithImage = {
allowAltText: true,
aspectRatio: '1:1'
}
};
}
// Square aspect ratio
export const SquareAspectRatio = {
@ -83,7 +83,7 @@ export const SquareAspectRatio = {
allowAltText: true,
required: true
}
};
}
// Wide aspect ratio
export const WideAspectRatio = {
@ -94,7 +94,7 @@ export const WideAspectRatio = {
allowAltText: true,
helpText: 'Recommended size: 1920x1080 pixels'
}
};
}
// Required field
export const Required = {
@ -104,7 +104,7 @@ export const Required = {
allowAltText: true,
placeholder: 'This field is required'
}
};
}
// With error
export const WithError = {
@ -113,7 +113,7 @@ export const WithError = {
error: 'Please select a valid image file',
allowAltText: true
}
};
}
// With help text
export const WithHelpText = {
@ -124,7 +124,7 @@ export const WithHelpText = {
aspectRatio: '1:1',
maxFileSize: 5
}
};
}
// Without alt text
export const WithoutAltText = {
@ -134,7 +134,7 @@ export const WithoutAltText = {
placeholder: 'Upload a decorative image',
helpText: 'This image is purely decorative and does not need alt text.'
}
};
}
// With browse library option
export const WithBrowseLibrary = {
@ -144,7 +144,7 @@ export const WithBrowseLibrary = {
showBrowseLibrary: true,
placeholder: 'Upload a new image or browse existing ones'
}
};
}
// Small file size limit
export const SmallFileLimit = {
@ -154,7 +154,7 @@ export const SmallFileLimit = {
allowAltText: true,
helpText: 'Maximum file size: 1MB'
}
};
}
// Interactive playground
export const Playground = {
@ -166,4 +166,4 @@ export const Playground = {
maxFileSize: 10,
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 {
title: 'Admin/Input',
@ -7,7 +7,19 @@ export default {
argTypes: {
type: {
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: {
control: { type: 'select' },
@ -50,7 +62,7 @@ export default {
control: 'number'
}
}
};
}
// Interactive Playground
export const Playground = {
@ -64,7 +76,7 @@ export const Playground = {
disabled: false,
readonly: false
}
};
}
// Basic text input
export const Basic = {
@ -73,7 +85,7 @@ export const Basic = {
label: 'Basic Input',
placeholder: 'Type something...'
}
};
}
// Email input
export const Email = {
@ -83,7 +95,7 @@ export const Email = {
placeholder: 'you@example.com',
required: true
}
};
}
// Password input
export const Password = {
@ -93,7 +105,7 @@ export const Password = {
placeholder: 'Enter your password',
required: true
}
};
}
// Search input
export const Search = {
@ -102,7 +114,7 @@ export const Search = {
label: 'Search',
placeholder: 'Search for something...'
}
};
}
// Number input
export const Number = {
@ -113,7 +125,7 @@ export const Number = {
min: 0,
max: 120
}
};
}
// Textarea
export const Textarea = {
@ -123,7 +135,7 @@ export const Textarea = {
placeholder: 'Tell us about yourself...',
rows: 4
}
};
}
// Auto-resizing textarea
export const AutoResizeTextarea = {
@ -134,7 +146,7 @@ export const AutoResizeTextarea = {
autoResize: true,
rows: 2
}
};
}
// With help text
export const WithHelpText = {
@ -144,7 +156,7 @@ export const WithHelpText = {
placeholder: 'you@example.com',
helpText: 'We will never share your email with anyone else.'
}
};
}
// With error
export const WithError = {
@ -155,7 +167,7 @@ export const WithError = {
error: 'Please enter a valid email address.',
required: true
}
};
}
// Character count
export const CharacterCount = {
@ -167,7 +179,7 @@ export const CharacterCount = {
showCharCount: true,
rows: 3
}
};
}
// Different sizes
export const Sizes = {
@ -181,7 +193,7 @@ export const Sizes = {
`,
components: { Input }
})
};
}
// Disabled state
export const Disabled = {
@ -191,7 +203,7 @@ export const Disabled = {
value: 'This input is disabled',
disabled: true
}
};
}
// Readonly state
export const Readonly = {
@ -201,7 +213,7 @@ export const Readonly = {
value: 'This input is readonly',
readonly: true
}
};
}
// With prefix icon
export const WithPrefixIcon = {
@ -223,7 +235,7 @@ export const WithPrefixIcon = {
`
}
})
};
}
// With suffix icon
export const WithSuffixIcon = {
@ -245,7 +257,7 @@ export const WithSuffixIcon = {
`
}
})
};
}
// Color input
export const ColorInput = {
@ -254,7 +266,7 @@ export const ColorInput = {
label: 'Pick a Color',
value: '#ff6b6b'
}
};
}
// Date input
export const DateInput = {
@ -263,7 +275,7 @@ export const DateInput = {
label: 'Select Date',
required: true
}
};
}
// Time input
export const TimeInput = {
@ -271,4 +283,4 @@ export const TimeInput = {
type: '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
const mockMedia = {
@ -13,7 +13,7 @@ const mockMedia = {
thumbnailUrl: 'https://via.placeholder.com/300x200/0066cc/ffffff?text=Sample+Image',
createdAt: new Date(),
updatedAt: new Date()
};
}
const mockMediaList = [
mockMedia,
@ -43,7 +43,7 @@ const mockMediaList = [
createdAt: new Date(),
updatedAt: new Date()
}
];
]
export default {
title: 'Admin/Form Components/MediaInput',
@ -71,7 +71,7 @@ export default {
control: 'text'
}
}
};
}
// Single media input (empty)
export const SingleEmpty = {
@ -82,7 +82,7 @@ export const SingleEmpty = {
placeholder: 'No image selected',
value: null
}
};
}
// Single media input (with value)
export const SingleWithValue = {
@ -92,7 +92,7 @@ export const SingleWithValue = {
fileType: 'image',
value: mockMedia
}
};
}
// Multiple media input (empty)
export const MultipleEmpty = {
@ -103,7 +103,7 @@ export const MultipleEmpty = {
placeholder: 'No images selected',
value: []
}
};
}
// Multiple media input (with values)
export const MultipleWithValues = {
@ -113,7 +113,7 @@ export const MultipleWithValues = {
fileType: 'image',
value: mockMediaList
}
};
}
// Required field
export const Required = {
@ -124,7 +124,7 @@ export const Required = {
required: true,
placeholder: 'Choose a logo image'
}
};
}
// With error state
export const WithError = {
@ -135,7 +135,7 @@ export const WithError = {
required: true,
error: 'Please select a profile picture'
}
};
}
// All file types
export const AllFileTypes = {
@ -145,7 +145,7 @@ export const AllFileTypes = {
fileType: 'all',
placeholder: 'Choose any media file'
}
};
}
// Video only
export const VideoOnly = {
@ -155,7 +155,7 @@ export const VideoOnly = {
fileType: 'video',
placeholder: 'Choose a video file'
}
};
}
// Multiple with many files
export const MultipleWithManyFiles = {
@ -193,7 +193,7 @@ export const MultipleWithManyFiles = {
}
]
}
};
}
// Interactive playground
export const Playground = {
@ -204,4 +204,4 @@ export const Playground = {
required: false,
placeholder: 'Select a file'
}
};
}

View file

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