Prettier + build errors
This commit is contained in:
parent
2a6291a547
commit
78443e2bdd
127 changed files with 2873 additions and 2292 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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/`)
|
||||
|
|
|
|||
|
|
@ -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']
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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.'
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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...
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
@ -229,7 +229,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
.composer-container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 []
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -577,7 +577,7 @@
|
|||
triggerElement={metadataButtonElement}
|
||||
onUpdate={handleMetadataUpdate}
|
||||
onDelete={handleMetadataDelete}
|
||||
onClose={() => isMetadataOpen = false}
|
||||
onClose={() => (isMetadataOpen = false)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -258,7 +258,7 @@
|
|||
triggerElement={metadataButtonElement}
|
||||
onUpdate={handleMetadataUpdate}
|
||||
onDelete={() => {}}
|
||||
onClose={() => isMetadataOpen = false}
|
||||
onClose={() => (isMetadataOpen = false)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 -->
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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' } }} />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
})();
|
||||
})()
|
||||
|
|
|
|||
Loading…
Reference in a new issue