Prettier + build errors

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

View file

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

View file

@ -1,42 +1,42 @@
import type { Preview } from '@storybook/sveltekit'; import type { Preview } from '@storybook/sveltekit'
import '../src/assets/styles/reset.css'; import '../src/assets/styles/reset.css'
import '../src/assets/styles/globals.scss'; import '../src/assets/styles/globals.scss'
const preview: Preview = { const preview: Preview = {
parameters: { parameters: {
actions: { argTypesRegex: '^on[A-Z].*' }, actions: { argTypesRegex: '^on[A-Z].*' },
controls: { controls: {
matchers: { matchers: {
color: /(background|color)$/i, color: /(background|color)$/i,
date: /Date$/, date: /Date$/
}, }
}, },
backgrounds: { backgrounds: {
default: 'light', default: 'light',
values: [ values: [
{ name: 'light', value: '#ffffff' }, { name: 'light', value: '#ffffff' },
{ name: 'dark', value: '#333333' }, { name: 'dark', value: '#333333' },
{ name: 'admin', value: '#f5f5f5' }, { name: 'admin', value: '#f5f5f5' },
{ name: 'grey-95', value: '#f8f9fa' }, { name: 'grey-95', value: '#f8f9fa' }
], ]
}, },
viewport: { viewport: {
viewports: { viewports: {
mobile: { mobile: {
name: 'Mobile', name: 'Mobile',
styles: { width: '375px', height: '667px' } styles: { width: '375px', height: '667px' }
}, },
tablet: { tablet: {
name: 'Tablet', name: 'Tablet',
styles: { width: '768px', height: '1024px' } styles: { width: '768px', height: '1024px' }
}, },
desktop: { desktop: {
name: 'Desktop', name: 'Desktop',
styles: { width: '1440px', height: '900px' } styles: { width: '1440px', height: '900px' }
}, }
}, }
}, }
}, }
}; }
export default preview; export default preview

View file

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

View file

@ -1,5 +1,5 @@
// For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format // For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format
import storybook from "eslint-plugin-storybook"; import storybook from 'eslint-plugin-storybook'
import js from '@eslint/js' import js from '@eslint/js'
import ts from 'typescript-eslint' import ts from 'typescript-eslint'
@ -9,12 +9,12 @@ import globals from 'globals'
/** @type {import('eslint').Linter.FlatConfig[]} */ /** @type {import('eslint').Linter.FlatConfig[]} */
export default [ export default [
js.configs.recommended, js.configs.recommended,
...ts.configs.recommended, ...ts.configs.recommended,
...svelte.configs['flat/recommended'], ...svelte.configs['flat/recommended'],
prettier, prettier,
...svelte.configs['flat/prettier'], ...svelte.configs['flat/prettier'],
{ {
languageOptions: { languageOptions: {
globals: { globals: {
...globals.browser, ...globals.browser,
@ -22,7 +22,7 @@ export default [
} }
} }
}, },
{ {
files: ['**/*.svelte'], files: ['**/*.svelte'],
languageOptions: { languageOptions: {
parserOptions: { parserOptions: {
@ -30,8 +30,8 @@ export default [
} }
} }
}, },
{ {
ignores: ['build/', '.svelte-kit/', 'dist/'] ignores: ['build/', '.svelte-kit/', 'dist/']
}, },
...storybook.configs["flat/recommended"] ...storybook.configs['flat/recommended']
]; ]

View file

@ -1,117 +1,117 @@
{ {
"name": "jedmund-svelte", "name": "jedmund-svelte",
"version": "0.0.1", "version": "0.0.1",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
"build": "vite build", "build": "vite build",
"start": "node build", "start": "node build",
"preview": "vite preview", "preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check . && eslint .", "lint": "prettier --check . && eslint .",
"format": "prettier --write .", "format": "prettier --write .",
"db:migrate": "prisma migrate dev", "db:migrate": "prisma migrate dev",
"db:seed": "prisma db seed", "db:seed": "prisma db seed",
"db:studio": "prisma studio", "db:studio": "prisma studio",
"setup:local": "./scripts/setup-local.sh", "setup:local": "./scripts/setup-local.sh",
"storybook": "storybook dev -p 6006", "storybook": "storybook dev -p 6006",
"build-storybook": "storybook build" "build-storybook": "storybook build"
}, },
"devDependencies": { "devDependencies": {
"@musicorum/lastfm": "github:jedmund/lastfm", "@musicorum/lastfm": "github:jedmund/lastfm",
"@poppanator/sveltekit-svg": "^5.0.0-svelte5.4", "@poppanator/sveltekit-svg": "^5.0.0-svelte5.4",
"@storybook/addon-a11y": "^9.0.1", "@storybook/addon-a11y": "^9.0.1",
"@storybook/addon-docs": "^9.0.1", "@storybook/addon-docs": "^9.0.1",
"@storybook/addon-svelte-csf": "^5.0.3", "@storybook/addon-svelte-csf": "^5.0.3",
"@storybook/sveltekit": "^9.0.1", "@storybook/sveltekit": "^9.0.1",
"@sveltejs/adapter-auto": "^3.0.0", "@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/kit": "^2.0.0", "@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^4.0.0-next.6", "@sveltejs/vite-plugin-svelte": "^4.0.0-next.6",
"@types/eslint": "^8.56.7", "@types/eslint": "^8.56.7",
"@types/node": "^22.0.2", "@types/node": "^22.0.2",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.19",
"eslint": "^9.0.0", "eslint": "^9.0.0",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-storybook": "^9.0.1", "eslint-plugin-storybook": "^9.0.1",
"eslint-plugin-svelte": "^2.36.0", "eslint-plugin-svelte": "^2.36.0",
"globals": "^15.0.0", "globals": "^15.0.0",
"postcss": "^8.4.39", "postcss": "^8.4.39",
"prettier": "^3.1.1", "prettier": "^3.1.1",
"prettier-plugin-svelte": "^3.1.2", "prettier-plugin-svelte": "^3.1.2",
"sass": "^1.77.8", "sass": "^1.77.8",
"storybook": "^9.0.1", "storybook": "^9.0.1",
"svelte": "^5.0.0-next.1", "svelte": "^5.0.0-next.1",
"svelte-check": "^3.6.0", "svelte-check": "^3.6.0",
"tslib": "^2.4.1", "tslib": "^2.4.1",
"tsx": "^4.19.4", "tsx": "^4.19.4",
"typescript": "^5.5.3", "typescript": "^5.5.3",
"typescript-eslint": "^8.0.0-alpha.20", "typescript-eslint": "^8.0.0-alpha.20",
"vite": "^5.0.3" "vite": "^5.0.3"
}, },
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@aarkue/tiptap-math-extension": "^1.3.6", "@aarkue/tiptap-math-extension": "^1.3.6",
"@prisma/client": "^6.8.2", "@prisma/client": "^6.8.2",
"@sveltejs/adapter-node": "^5.2.0", "@sveltejs/adapter-node": "^5.2.0",
"@tiptap/core": "^2.12.0", "@tiptap/core": "^2.12.0",
"@tiptap/extension-bubble-menu": "^2.12.0", "@tiptap/extension-bubble-menu": "^2.12.0",
"@tiptap/extension-character-count": "^2.12.0", "@tiptap/extension-character-count": "^2.12.0",
"@tiptap/extension-code-block-lowlight": "^2.12.0", "@tiptap/extension-code-block-lowlight": "^2.12.0",
"@tiptap/extension-color": "^2.12.0", "@tiptap/extension-color": "^2.12.0",
"@tiptap/extension-floating-menu": "^2.12.0", "@tiptap/extension-floating-menu": "^2.12.0",
"@tiptap/extension-highlight": "^2.12.0", "@tiptap/extension-highlight": "^2.12.0",
"@tiptap/extension-image": "^2.12.0", "@tiptap/extension-image": "^2.12.0",
"@tiptap/extension-link": "^2.12.0", "@tiptap/extension-link": "^2.12.0",
"@tiptap/extension-placeholder": "^2.12.0", "@tiptap/extension-placeholder": "^2.12.0",
"@tiptap/extension-subscript": "^2.12.0", "@tiptap/extension-subscript": "^2.12.0",
"@tiptap/extension-superscript": "^2.12.0", "@tiptap/extension-superscript": "^2.12.0",
"@tiptap/extension-table": "^2.12.0", "@tiptap/extension-table": "^2.12.0",
"@tiptap/extension-table-header": "^2.12.0", "@tiptap/extension-table-header": "^2.12.0",
"@tiptap/extension-table-row": "^2.12.0", "@tiptap/extension-table-row": "^2.12.0",
"@tiptap/extension-task-item": "^2.12.0", "@tiptap/extension-task-item": "^2.12.0",
"@tiptap/extension-task-list": "^2.12.0", "@tiptap/extension-task-list": "^2.12.0",
"@tiptap/extension-text": "^2.12.0", "@tiptap/extension-text": "^2.12.0",
"@tiptap/extension-text-align": "^2.12.0", "@tiptap/extension-text-align": "^2.12.0",
"@tiptap/extension-text-style": "^2.12.0", "@tiptap/extension-text-style": "^2.12.0",
"@tiptap/extension-typography": "^2.12.0", "@tiptap/extension-typography": "^2.12.0",
"@tiptap/extension-underline": "^2.12.0", "@tiptap/extension-underline": "^2.12.0",
"@tiptap/pm": "^2.12.0", "@tiptap/pm": "^2.12.0",
"@tiptap/starter-kit": "^2.12.0", "@tiptap/starter-kit": "^2.12.0",
"@tiptap/suggestion": "^2.12.0", "@tiptap/suggestion": "^2.12.0",
"@types/multer": "^1.4.12", "@types/multer": "^1.4.12",
"@types/redis": "^4.0.10", "@types/redis": "^4.0.10",
"@types/steamapi": "^2.2.5", "@types/steamapi": "^2.2.5",
"cloudinary": "^2.6.1", "cloudinary": "^2.6.1",
"dotenv": "^16.5.0", "dotenv": "^16.5.0",
"exifr": "^7.1.3", "exifr": "^7.1.3",
"giantbombing-api": "^1.0.4", "giantbombing-api": "^1.0.4",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
"ioredis": "^5.4.1", "ioredis": "^5.4.1",
"katex": "^0.16.22", "katex": "^0.16.22",
"lowlight": "^3.3.0", "lowlight": "^3.3.0",
"lucide-svelte": "^0.511.0", "lucide-svelte": "^0.511.0",
"marked": "^15.0.12", "marked": "^15.0.12",
"multer": "^2.0.0", "multer": "^2.0.0",
"node-itunes-search": "^1.2.3", "node-itunes-search": "^1.2.3",
"prisma": "^6.8.2", "prisma": "^6.8.2",
"psn-api": "github:jedmund/psn-api", "psn-api": "github:jedmund/psn-api",
"redis": "^4.7.0", "redis": "^4.7.0",
"sharp": "^0.34.2", "sharp": "^0.34.2",
"steamapi": "^3.0.11", "steamapi": "^3.0.11",
"svelte-tiptap": "^2.1.0", "svelte-tiptap": "^2.1.0",
"svgo": "^3.3.2", "svgo": "^3.3.2",
"tinyduration": "^3.3.1", "tinyduration": "^3.3.1",
"tippy.js": "^6.3.7", "tippy.js": "^6.3.7",
"tiptap-extension-auto-joiner": "^0.1.3", "tiptap-extension-auto-joiner": "^0.1.3",
"tiptap-extension-global-drag-handle": "^0.1.18", "tiptap-extension-global-drag-handle": "^0.1.18",
"tiptap-markdown": "^0.8.10", "tiptap-markdown": "^0.8.10",
"zod": "^3.25.30" "zod": "^3.25.30"
}, },
"prisma": { "prisma": {
"seed": "tsx prisma/seed.ts" "seed": "tsx prisma/seed.ts"
}, },
"overrides": { "overrides": {
"@sveltejs/vite-plugin-svelte": "^4.0.0-next.6" "@sveltejs/vite-plugin-svelte": "^4.0.0-next.6"
} }
} }

View file

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

View file

@ -15,9 +15,9 @@ export function getAuthHeaders(): HeadersInit {
// In production, this should redirect to login // In production, this should redirect to login
adminCredentials = btoa('admin:localdev') adminCredentials = btoa('admin:localdev')
} }
return { return {
'Authorization': `Basic ${adminCredentials}` Authorization: `Basic ${adminCredentials}`
} }
} }
@ -32,14 +32,17 @@ export function clearAuth() {
} }
// Make authenticated API request // Make authenticated API request
export async function authenticatedFetch(url: string, options: RequestInit = {}): Promise<Response> { export async function authenticatedFetch(
url: string,
options: RequestInit = {}
): Promise<Response> {
const headers = { const headers = {
...getAuthHeaders(), ...getAuthHeaders(),
...options.headers ...options.headers
} }
return fetch(url, { return fetch(url, {
...options, ...options,
headers headers
}) })
} }

View file

@ -11,11 +11,11 @@
display: inline-block; display: inline-block;
width: 100%; width: 100%;
height: 100%; height: 100%;
:global(svg) { :global(svg) {
width: 100%; width: 100%;
height: 100%; height: 100%;
border-radius: $avatar-radius; border-radius: $avatar-radius;
} }
} }
</style> </style>

View file

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

View file

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

View file

@ -2,12 +2,12 @@
import PhotoItem from '$components/PhotoItem.svelte' import PhotoItem from '$components/PhotoItem.svelte'
import type { PhotoItem as PhotoItemType } from '$lib/types/photos' import type { PhotoItem as PhotoItemType } from '$lib/types/photos'
const { const {
photoItems, photoItems,
albumSlug albumSlug
}: { }: {
photoItems: PhotoItemType[] photoItems: PhotoItemType[]
albumSlug?: string albumSlug?: string
} = $props() } = $props()
</script> </script>

View file

@ -278,4 +278,4 @@
color: $grey-20; color: $grey-20;
} }
} }
</style> </style>

View file

@ -32,7 +32,7 @@
error = '' error = ''
// Simulate a small delay for better UX // Simulate a small delay for better UX
await new Promise(resolve => setTimeout(resolve, 500)) await new Promise((resolve) => setTimeout(resolve, 500))
if (password === correctPassword) { if (password === correctPassword) {
// Store in session storage // Store in session storage
@ -63,7 +63,13 @@
{#snippet passwordHeader()} {#snippet passwordHeader()}
<div class="password-header"> <div class="password-header">
<div class="lock-icon"> <div class="lock-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg
width="48"
height="48"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path <path
d="M18 11H6C5.45 11 5 11.45 5 12V19C5 19.55 5.45 20 6 20H18C18.55 20 19 19.55 19 19V12C19 11.45 18.55 11 18 11Z" d="M18 11H6C5.45 11 5 11.45 5 12V19C5 19.55 5.45 20 6 20H18C18.55 20 19 19.55 19 19V12C19 11.45 18.55 11 18 11Z"
stroke="currentColor" stroke="currentColor"
@ -98,9 +104,9 @@
onkeypress={handleKeyPress} onkeypress={handleKeyPress}
disabled={isLoading} disabled={isLoading}
/> />
<Button <Button
variant="primary" variant="primary"
onclick={handleSubmit} onclick={handleSubmit}
disabled={isLoading || !password.trim()} disabled={isLoading || !password.trim()}
class="submit-button" class="submit-button"
> >
@ -135,7 +141,7 @@
.lock-icon { .lock-icon {
color: $grey-40; color: $grey-40;
margin-bottom: $unit-3x; margin-bottom: $unit-3x;
svg { svg {
display: block; display: block;
margin: 0 auto; margin: 0 auto;
@ -191,7 +197,9 @@
border: 1px solid $grey-80; border: 1px solid $grey-80;
border-radius: $unit; border-radius: $unit;
font-size: 1rem; font-size: 1rem;
transition: border-color 0.2s ease, box-shadow 0.2s ease; transition:
border-color 0.2s ease,
box-shadow 0.2s ease;
&:focus { &:focus {
outline: none; outline: none;
@ -240,4 +248,4 @@
} }
} }
} }
</style> </style>

View file

@ -41,9 +41,9 @@
actualContainerWidth = entry.contentRect.width actualContainerWidth = entry.contentRect.width
} }
}) })
resizeObserver.observe(imgElement.parentElement) resizeObserver.observe(imgElement.parentElement)
return () => { return () => {
resizeObserver.disconnect() resizeObserver.disconnect()
} }
@ -53,12 +53,12 @@
// Smart image URL selection // Smart image URL selection
function getImageUrl(): string { function getImageUrl(): string {
if (!media.url) return '' if (!media.url) return ''
// SVG files should always use the original URL (they're vector, no thumbnails needed) // SVG files should always use the original URL (they're vector, no thumbnails needed)
if (media.mimeType === 'image/svg+xml' || media.url.endsWith('.svg')) { if (media.mimeType === 'image/svg+xml' || media.url.endsWith('.svg')) {
return media.url return media.url
} }
// For local development, use what we have // For local development, use what we have
if (media.url.startsWith('/local-uploads')) { if (media.url.startsWith('/local-uploads')) {
// For larger containers, prefer original over thumbnail // For larger containers, prefer original over thumbnail
@ -82,7 +82,7 @@
if (media.mimeType === 'image/svg+xml' || media.url.endsWith('.svg')) { if (media.mimeType === 'image/svg+xml' || media.url.endsWith('.svg')) {
return '' return ''
} }
if (!media.url || media.url.startsWith('/local-uploads')) { if (!media.url || media.url.startsWith('/local-uploads')) {
// For local images, just provide the main options // For local images, just provide the main options
const sources = [] const sources = []
@ -103,11 +103,11 @@
// Compute styles // Compute styles
function getImageStyles(): string { function getImageStyles(): string {
let styles = '' let styles = ''
if (aspectRatio) { if (aspectRatio) {
styles += `aspect-ratio: ${aspectRatio.replace(':', '/')};` styles += `aspect-ratio: ${aspectRatio.replace(':', '/')};`
} }
return styles return styles
} }
</script> </script>
@ -130,4 +130,4 @@
max-width: 100%; max-width: 100%;
height: auto; height: auto;
} }
</style> </style>

View file

@ -17,9 +17,7 @@
<article class="universe-album-card"> <article class="universe-album-card">
<div class="card-content"> <div class="card-content">
<div class="card-header"> <div class="card-header">
<div class="album-type-badge"> <div class="album-type-badge">Album</div>
Album
</div>
<time class="album-date" datetime={album.publishedAt}> <time class="album-date" datetime={album.publishedAt}>
{formatDate(album.publishedAt)} {formatDate(album.publishedAt)}
</time> </time>
@ -27,8 +25,8 @@
{#if album.coverPhoto} {#if album.coverPhoto}
<div class="album-cover"> <div class="album-cover">
<img <img
src={album.coverPhoto.thumbnailUrl || album.coverPhoto.url} src={album.coverPhoto.thumbnailUrl || album.coverPhoto.url}
alt={album.coverPhoto.caption || album.title} alt={album.coverPhoto.caption || album.title}
loading="lazy" loading="lazy"
/> />
@ -60,9 +58,7 @@
</div> </div>
<div class="card-footer"> <div class="card-footer">
<a href="/photos/{album.slug}" class="view-album"> <a href="/photos/{album.slug}" class="view-album"> View album → </a>
View album →
</a>
<UniverseIcon class="universe-icon" /> <UniverseIcon class="universe-icon" />
</div> </div>
</div> </div>
@ -217,4 +213,4 @@
height: 16px; height: 16px;
fill: $grey-40; fill: $grey-40;
} }
</style> </style>

View file

@ -39,4 +39,4 @@
font-size: 1.125rem; font-size: 1.125rem;
} }
} }
</style> </style>

View file

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

View file

@ -40,4 +40,4 @@
gap: $unit-half; gap: $unit-half;
} }
} }
</style> </style>

View file

@ -36,4 +36,4 @@
align-items: center; align-items: center;
gap: $unit-2x; gap: $unit-2x;
} }
</style> </style>

View file

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

View file

@ -50,19 +50,19 @@
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000) const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000)
if (diffInSeconds < 60) return 'just now' if (diffInSeconds < 60) return 'just now'
const minutes = Math.floor(diffInSeconds / 60) const minutes = Math.floor(diffInSeconds / 60)
if (diffInSeconds < 3600) return `${minutes} ${minutes === 1 ? 'minute' : 'minutes'} ago` if (diffInSeconds < 3600) return `${minutes} ${minutes === 1 ? 'minute' : 'minutes'} ago`
const hours = Math.floor(diffInSeconds / 3600) const hours = Math.floor(diffInSeconds / 3600)
if (diffInSeconds < 86400) return `${hours} ${hours === 1 ? 'hour' : 'hours'} ago` if (diffInSeconds < 86400) return `${hours} ${hours === 1 ? 'hour' : 'hours'} ago`
const days = Math.floor(diffInSeconds / 86400) const days = Math.floor(diffInSeconds / 86400)
if (diffInSeconds < 2592000) return `${days} ${days === 1 ? 'day' : 'days'} ago` if (diffInSeconds < 2592000) return `${days} ${days === 1 ? 'day' : 'days'} ago`
const months = Math.floor(diffInSeconds / 2592000) const months = Math.floor(diffInSeconds / 2592000)
if (diffInSeconds < 31536000) return `${months} ${months === 1 ? 'month' : 'months'} ago` if (diffInSeconds < 31536000) return `${months} ${months === 1 ? 'month' : 'months'} ago`
const years = Math.floor(diffInSeconds / 31536000) const years = Math.floor(diffInSeconds / 31536000)
return `${years} ${years === 1 ? 'year' : 'years'} ago` return `${years} ${years === 1 ? 'year' : 'years'} ago`
} }
@ -90,17 +90,17 @@
// Get thumbnail - try cover photo first, then first photo // Get thumbnail - try cover photo first, then first photo
function getThumbnailUrl(): string | null { function getThumbnailUrl(): string | null {
if (album.coverPhotoId && album.photos.length > 0) { if (album.coverPhotoId && album.photos.length > 0) {
const coverPhoto = album.photos.find(p => p.id === album.coverPhotoId) const coverPhoto = album.photos.find((p) => p.id === album.coverPhotoId)
if (coverPhoto) { if (coverPhoto) {
return coverPhoto.thumbnailUrl || coverPhoto.url return coverPhoto.thumbnailUrl || coverPhoto.url
} }
} }
// Fallback to first photo // Fallback to first photo
if (album.photos.length > 0) { if (album.photos.length > 0) {
return album.photos[0].thumbnailUrl || album.photos[0].url return album.photos[0].thumbnailUrl || album.photos[0].url
} }
return null return null
} }
@ -133,15 +133,15 @@
<div class="album-info"> <div class="album-info">
<h3 class="album-title">{album.title}</h3> <h3 class="album-title">{album.title}</h3>
<AdminByline <AdminByline
sections={[ sections={[
album.isPhotography ? 'Photography' : 'Album', album.isPhotography ? 'Photography' : 'Album',
album.status === 'published' ? 'Published' : 'Draft', album.status === 'published' ? 'Published' : 'Draft',
`${getPhotoCount()} ${getPhotoCount() === 1 ? 'photo' : 'photos'}`, `${getPhotoCount()} ${getPhotoCount() === 1 ? 'photo' : 'photos'}`,
album.status === 'published' && album.publishedAt album.status === 'published' && album.publishedAt
? `Published ${formatRelativeTime(album.publishedAt)}` ? `Published ${formatRelativeTime(album.publishedAt)}`
: `Created ${formatRelativeTime(album.createdAt)}` : `Created ${formatRelativeTime(album.createdAt)}`
]} ]}
/> />
</div> </div>
@ -300,4 +300,4 @@
background-color: $grey-90; background-color: $grey-90;
margin: $unit-half 0; margin: $unit-half 0;
} }
</style> </style>

View file

@ -95,10 +95,10 @@
}) })
</script> </script>
<GenericMetadataPopover <GenericMetadataPopover
{config} {config}
bind:data={popoverData} bind:data={popoverData}
{triggerElement} {triggerElement}
onUpdate={handleDateChange} onUpdate={handleDateChange}
{onClose} {onClose}
/> />

View file

@ -80,13 +80,13 @@
font-size: 1.25rem; font-size: 1.25rem;
font-weight: 700; font-weight: 700;
color: $grey-10; color: $grey-10;
} }
p { p {
margin: 0 0 $unit-4x; margin: 0 0 $unit-4x;
color: $grey-20; color: $grey-20;
line-height: 1.5; line-height: 1.5;
} }
} }
.modal-actions { .modal-actions {

View file

@ -5,12 +5,7 @@
disabled?: boolean disabled?: boolean
} }
let { let { onclick, variant = 'default', disabled = false, children }: Props = $props()
onclick,
variant = 'default',
disabled = false,
children
}: Props = $props()
function handleClick(event: MouseEvent) { function handleClick(event: MouseEvent) {
if (disabled) return if (disabled) return
@ -56,4 +51,4 @@
cursor: not-allowed; cursor: not-allowed;
} }
} }
</style> </style>

View file

@ -17,12 +17,7 @@
divider?: boolean divider?: boolean
} }
let { let { isOpen = $bindable(), triggerElement, items, onClose }: Props = $props()
isOpen = $bindable(),
triggerElement,
items,
onClose
}: Props = $props()
let dropdownElement: HTMLDivElement let dropdownElement: HTMLDivElement
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
@ -35,7 +30,7 @@
const rect = triggerElement.getBoundingClientRect() const rect = triggerElement.getBoundingClientRect()
const dropdownWidth = 180 const dropdownWidth = 180
return { return {
top: rect.bottom + 4, top: rect.bottom + 4,
left: rect.right - dropdownWidth left: rect.right - dropdownWidth
@ -51,7 +46,7 @@
function handleOutsideClick(event: MouseEvent) { function handleOutsideClick(event: MouseEvent) {
if (!dropdownElement || !isOpen) return if (!dropdownElement || !isOpen) return
const target = event.target as HTMLElement const target = event.target as HTMLElement
if (!dropdownElement.contains(target) && !triggerElement?.contains(target)) { if (!dropdownElement.contains(target) && !triggerElement?.contains(target)) {
isOpen = false isOpen = false
@ -131,4 +126,4 @@
background-color: $grey-90; background-color: $grey-90;
margin: $unit-half 0; margin: $unit-half 0;
} }
</style> </style>

View file

@ -25,4 +25,4 @@
min-width: 180px; min-width: 180px;
z-index: 10; z-index: 10;
} }
</style> </style>

View file

@ -71,7 +71,7 @@
const timer = setTimeout(() => { const timer = setTimeout(() => {
editor.commands.focus() editor.commands.focus()
}, 100) }, 100)
return () => clearTimeout(timer) return () => clearTimeout(timer)
} }
}) })

View file

@ -212,7 +212,9 @@
if (!clipboardData) return false if (!clipboardData) return false
// Check for images first // Check for images first
const imageItem = Array.from(clipboardData.items).find(item => item.type.indexOf('image') === 0) const imageItem = Array.from(clipboardData.items).find(
(item) => item.type.indexOf('image') === 0
)
if (imageItem) { if (imageItem) {
const file = imageItem.getAsFile() const file = imageItem.getAsFile()
if (!file) return false if (!file) return false
@ -232,11 +234,11 @@
// Handle text paste - strip HTML formatting // Handle text paste - strip HTML formatting
const htmlData = clipboardData.getData('text/html') const htmlData = clipboardData.getData('text/html')
const plainText = clipboardData.getData('text/plain') const plainText = clipboardData.getData('text/plain')
if (htmlData && plainText) { if (htmlData && plainText) {
// If we have both HTML and plain text, use plain text to strip formatting // If we have both HTML and plain text, use plain text to strip formatting
event.preventDefault() event.preventDefault()
// Use editor commands to insert text so all callbacks are triggered // Use editor commands to insert text so all callbacks are triggered
const editorInstance = (view as any).editor const editorInstance = (view as any).editor
if (editorInstance) { if (editorInstance) {
@ -248,7 +250,7 @@
const transaction = state.tr.insertText(plainText, selection.from, selection.to) const transaction = state.tr.insertText(plainText, selection.from, selection.to)
dispatch(transaction) dispatch(transaction)
} }
return true // Prevent default paste behavior return true // Prevent default paste behavior
} }

View file

@ -264,18 +264,9 @@
}} }}
> >
<div class="form-section"> <div class="form-section">
<Input <Input label="Title" bind:value={title} required placeholder="Essay title" />
label="Title"
bind:value={title}
required
placeholder="Essay title"
/>
<Input <Input label="Slug" bind:value={slug} placeholder="essay-url-slug" />
label="Slug"
bind:value={slug}
placeholder="essay-url-slug"
/>
<Input <Input
type="textarea" type="textarea"
@ -366,7 +357,6 @@
} }
} }
.admin-container { .admin-container {
width: 100%; width: 100%;
margin: 0 auto; margin: 0 auto;
@ -383,15 +373,14 @@
display: flex; display: flex;
} }
// Custom styles for save/publish buttons to maintain grey color scheme // Custom styles for save/publish buttons to maintain grey color scheme
:global(.save-button.btn-primary) { :global(.save-button.btn-primary) {
background-color: $grey-10; background-color: $grey-10;
&:hover:not(:disabled) { &:hover:not(:disabled) {
background-color: $grey-20; background-color: $grey-20;
} }
&:active:not(:disabled) { &:active:not(:disabled) {
background-color: $grey-30; background-color: $grey-30;
} }
@ -405,15 +394,15 @@
:global(.chevron-button.btn-primary) { :global(.chevron-button.btn-primary) {
background-color: $grey-10; background-color: $grey-10;
&:hover:not(:disabled) { &:hover:not(:disabled) {
background-color: $grey-20; background-color: $grey-20;
} }
&:active:not(:disabled) { &:active:not(:disabled) {
background-color: $grey-30; background-color: $grey-30;
} }
&.active { &.active {
background-color: $grey-20; background-color: $grey-20;
} }
@ -510,14 +499,14 @@
// Tags field styles // Tags field styles
.tags-field { .tags-field {
margin-bottom: $unit-4x; margin-bottom: $unit-4x;
.input-label { .input-label {
display: block; display: block;
margin-bottom: $unit; margin-bottom: $unit;
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
color: $grey-20; color: $grey-20;
} }
} }
.tag-input-wrapper { .tag-input-wrapper {

View file

@ -27,16 +27,16 @@
function handleImagesSelect(media: Media[]) { function handleImagesSelect(media: Media[]) {
// Add new images to existing ones, avoiding duplicates // Add new images to existing ones, avoiding duplicates
const existingIds = new Set(value.map(item => item.id)) const existingIds = new Set(value.map((item) => item.id))
const newImages = media.filter(item => !existingIds.has(item.id)) const newImages = media.filter((item) => !existingIds.has(item.id))
if (maxItems) { if (maxItems) {
const availableSlots = maxItems - value.length const availableSlots = maxItems - value.length
value = [...value, ...newImages.slice(0, availableSlots)] value = [...value, ...newImages.slice(0, availableSlots)]
} else { } else {
value = [...value, ...newImages] value = [...value, ...newImages]
} }
showModal = false showModal = false
} }
@ -51,11 +51,11 @@
// Drag and Drop functionality // Drag and Drop functionality
function handleDragStart(event: DragEvent, index: number) { function handleDragStart(event: DragEvent, index: number) {
if (!event.dataTransfer) return if (!event.dataTransfer) return
draggedIndex = index draggedIndex = index
event.dataTransfer.effectAllowed = 'move' event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData('text/html', '') event.dataTransfer.setData('text/html', '')
// Add dragging class to the dragged element // Add dragging class to the dragged element
const target = event.target as HTMLElement const target = event.target as HTMLElement
target.style.opacity = '0.5' target.style.opacity = '0.5'
@ -64,7 +64,7 @@
function handleDragEnd(event: DragEvent) { function handleDragEnd(event: DragEvent) {
const target = event.target as HTMLElement const target = event.target as HTMLElement
target.style.opacity = '1' target.style.opacity = '1'
draggedIndex = null draggedIndex = null
dragOverIndex = null dragOverIndex = null
} }
@ -72,7 +72,7 @@
function handleDragOver(event: DragEvent, index: number) { function handleDragOver(event: DragEvent, index: number) {
event.preventDefault() event.preventDefault()
if (!event.dataTransfer) return if (!event.dataTransfer) return
event.dataTransfer.dropEffect = 'move' event.dataTransfer.dropEffect = 'move'
dragOverIndex = index dragOverIndex = index
} }
@ -83,7 +83,7 @@
function handleDrop(event: DragEvent, dropIndex: number) { function handleDrop(event: DragEvent, dropIndex: number) {
event.preventDefault() event.preventDefault()
if (draggedIndex === null || draggedIndex === dropIndex) { if (draggedIndex === null || draggedIndex === dropIndex) {
return return
} }
@ -91,16 +91,16 @@
// Reorder the array // Reorder the array
const newValue = [...value] const newValue = [...value]
const draggedItem = newValue[draggedIndex] const draggedItem = newValue[draggedIndex]
// Remove the dragged item // Remove the dragged item
newValue.splice(draggedIndex, 1) newValue.splice(draggedIndex, 1)
// Insert at the new position (adjust index if necessary) // Insert at the new position (adjust index if necessary)
const insertIndex = draggedIndex < dropIndex ? dropIndex - 1 : dropIndex const insertIndex = draggedIndex < dropIndex ? dropIndex - 1 : dropIndex
newValue.splice(insertIndex, 0, draggedItem) newValue.splice(insertIndex, 0, draggedItem)
value = newValue value = newValue
// Reset drag state // Reset drag state
draggedIndex = null draggedIndex = null
dragOverIndex = null dragOverIndex = null
@ -117,10 +117,8 @@
// Computed properties // Computed properties
const hasImages = $derived(value.length > 0) const hasImages = $derived(value.length > 0)
const canAddMore = $derived(!maxItems || value.length < maxItems) const canAddMore = $derived(!maxItems || value.length < maxItems)
const selectedIds = $derived(value.map(item => item.id)) const selectedIds = $derived(value.map((item) => item.id))
const itemsText = $derived( const itemsText = $derived(value.length === 1 ? '1 image' : `${value.length} images`)
value.length === 1 ? '1 image' : `${value.length} images`
)
</script> </script>
<div class="gallery-manager"> <div class="gallery-manager">
@ -131,7 +129,7 @@
<span class="required">*</span> <span class="required">*</span>
{/if} {/if}
</label> </label>
{#if hasImages} {#if hasImages}
<span class="items-count"> <span class="items-count">
{itemsText} {itemsText}
@ -146,7 +144,7 @@
{#if hasImages} {#if hasImages}
<div class="gallery-grid" class:has-error={error}> <div class="gallery-grid" class:has-error={error}>
{#each value as item, index (item.id)} {#each value as item, index (item.id)}
<div <div
class="gallery-item" class="gallery-item"
class:drag-over={dragOverIndex === index} class:drag-over={dragOverIndex === index}
draggable="true" draggable="true"
@ -158,13 +156,19 @@
> >
<!-- Drag Handle --> <!-- Drag Handle -->
<div class="drag-handle"> <div class="drag-handle">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg
<circle cx="9" cy="12" r="1" fill="currentColor"/> width="12"
<circle cx="9" cy="5" r="1" fill="currentColor"/> height="12"
<circle cx="9" cy="19" r="1" fill="currentColor"/> viewBox="0 0 24 24"
<circle cx="15" cy="12" r="1" fill="currentColor"/> fill="none"
<circle cx="15" cy="5" r="1" fill="currentColor"/> xmlns="http://www.w3.org/2000/svg"
<circle cx="15" cy="19" r="1" fill="currentColor"/> >
<circle cx="9" cy="12" r="1" fill="currentColor" />
<circle cx="9" cy="5" r="1" fill="currentColor" />
<circle cx="9" cy="19" r="1" fill="currentColor" />
<circle cx="15" cy="12" r="1" fill="currentColor" />
<circle cx="15" cy="5" r="1" fill="currentColor" />
<circle cx="15" cy="19" r="1" fill="currentColor" />
</svg> </svg>
</div> </div>
@ -174,10 +178,29 @@
<img src={item.thumbnailUrl} alt={item.filename} /> <img src={item.thumbnailUrl} alt={item.filename} />
{:else} {:else}
<div class="image-placeholder"> <div class="image-placeholder">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg
<rect x="3" y="5" width="18" height="14" rx="2" stroke="currentColor" stroke-width="2"/> width="24"
<circle cx="8.5" cy="8.5" r=".5" fill="currentColor"/> height="24"
<path d="M3 16l5-5 3 3 4-4 4 4" stroke="currentColor" stroke-width="2" fill="none"/> viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect
x="3"
y="5"
width="18"
height="14"
rx="2"
stroke="currentColor"
stroke-width="2"
/>
<circle cx="8.5" cy="8.5" r=".5" fill="currentColor" />
<path
d="M3 16l5-5 3 3 4-4 4 4"
stroke="currentColor"
stroke-width="2"
fill="none"
/>
</svg> </svg>
</div> </div>
{/if} {/if}
@ -192,13 +215,19 @@
{/if} {/if}
<!-- Remove Button --> <!-- Remove Button -->
<button <button
type="button" type="button"
class="remove-button" class="remove-button"
onclick={() => removeImage(index)} onclick={() => removeImage(index)}
aria-label="Remove image" aria-label="Remove image"
> >
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path <path
d="M6 6L18 18M6 18L18 6" d="M6 6L18 18M6 18L18 6"
stroke="currentColor" stroke="currentColor"
@ -217,13 +246,15 @@
<!-- Add More Button (if within grid) --> <!-- Add More Button (if within grid) -->
{#if canAddMore} {#if canAddMore}
<button <button type="button" class="add-more-item" onclick={openModal}>
type="button"
class="add-more-item"
onclick={openModal}
>
<div class="add-icon"> <div class="add-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path <path
d="M12 5v14m-7-7h14" d="M12 5v14m-7-7h14"
stroke="currentColor" stroke="currentColor"
@ -241,10 +272,24 @@
<div class="empty-state" class:has-error={error}> <div class="empty-state" class:has-error={error}>
<div class="empty-content"> <div class="empty-content">
<div class="empty-icon"> <div class="empty-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg
<rect x="3" y="5" width="18" height="14" rx="2" stroke="currentColor" stroke-width="1.5"/> width="48"
<circle cx="8.5" cy="8.5" r=".5" fill="currentColor"/> height="48"
<path d="M3 16l5-5 3 3 4-4 4 4" stroke="currentColor" stroke-width="1.5" fill="none"/> viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect
x="3"
y="5"
width="18"
height="14"
rx="2"
stroke="currentColor"
stroke-width="1.5"
/>
<circle cx="8.5" cy="8.5" r=".5" fill="currentColor" />
<path d="M3 16l5-5 3 3 4-4 4 4" stroke="currentColor" stroke-width="1.5" fill="none" />
</svg> </svg>
</div> </div>
<p class="empty-text">No images added yet</p> <p class="empty-text">No images added yet</p>
@ -598,4 +643,4 @@
display: none; // Hide on mobile to save space display: none; // Hide on mobile to save space
} }
} }
</style> </style>

View file

@ -135,10 +135,7 @@
const handleClickOutside = (event: MouseEvent) => { const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node const target = event.target as Node
// Don't close if clicking inside the trigger button or the popover itself // Don't close if clicking inside the trigger button or the popover itself
if ( if (triggerElement?.contains(target) || popoverElement?.contains(target)) {
triggerElement?.contains(target) ||
popoverElement?.contains(target)
) {
return return
} }
onClose() onClose()
@ -172,7 +169,7 @@
{#each config.fields as field} {#each config.fields as field}
{#if field.type === 'input'} {#if field.type === 'input'}
<Input <Input
label={field.label} label={field.label}
bind:value={data[field.key]} bind:value={data[field.key]}
placeholder={field.placeholder} placeholder={field.placeholder}
@ -180,7 +177,7 @@
onchange={() => handleFieldUpdate(field.key, data[field.key])} onchange={() => handleFieldUpdate(field.key, data[field.key])}
/> />
{:else if field.type === 'textarea'} {:else if field.type === 'textarea'}
<Input <Input
type="textarea" type="textarea"
label={field.label} label={field.label}
bind:value={data[field.key]} bind:value={data[field.key]}
@ -190,7 +187,7 @@
onchange={() => handleFieldUpdate(field.key, data[field.key])} onchange={() => handleFieldUpdate(field.key, data[field.key])}
/> />
{:else if field.type === 'date'} {:else if field.type === 'date'}
<Input <Input
type="date" type="date"
label={field.label} label={field.label}
bind:value={data[field.key]} bind:value={data[field.key]}
@ -217,14 +214,14 @@
</div> </div>
{:else if field.type === 'tags'} {:else if field.type === 'tags'}
<div class="tags-section"> <div class="tags-section">
<Input <Input
label={field.label} label={field.label}
bind:value={data.tagInput} bind:value={data.tagInput}
onkeydown={(e) => e.key === 'Enter' && (e.preventDefault(), onAddTag())} onkeydown={(e) => e.key === 'Enter' && (e.preventDefault(), onAddTag())}
placeholder={field.placeholder || "Add tags..."} placeholder={field.placeholder || 'Add tags...'}
/> />
<button type="button" onclick={onAddTag} class="add-tag-btn">Add</button> <button type="button" onclick={onAddTag} class="add-tag-btn">Add</button>
{#if data[field.key] && data[field.key].length > 0} {#if data[field.key] && data[field.key].length > 0}
<div class="tags"> <div class="tags">
{#each data[field.key] as tag} {#each data[field.key] as tag}
@ -459,11 +456,10 @@
} }
} }
@include breakpoint('phone') { @include breakpoint('phone') {
.metadata-popover { .metadata-popover {
min-width: 280px; min-width: 280px;
max-width: calc(100vw - 2rem); max-width: calc(100vw - 2rem);
} }
} }
</style> </style>

View file

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

View file

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

View file

@ -76,11 +76,11 @@
async function uploadFile(file: File): Promise<Media> { async function uploadFile(file: File): Promise<Media> {
const formData = new FormData() const formData = new FormData()
formData.append('file', file) formData.append('file', file)
if (allowAltText && altTextValue.trim()) { if (allowAltText && altTextValue.trim()) {
formData.append('altText', altTextValue.trim()) formData.append('altText', altTextValue.trim())
} }
if (allowAltText && descriptionValue.trim()) { if (allowAltText && descriptionValue.trim()) {
formData.append('description', descriptionValue.trim()) formData.append('description', descriptionValue.trim())
} }
@ -104,7 +104,7 @@
const file = files[0] const file = files[0]
const validationError = validateFile(file) const validationError = validateFile(file)
if (validationError) { if (validationError) {
uploadError = validationError uploadError = validationError
return return
@ -123,10 +123,10 @@
}, 100) }, 100)
const uploadedMedia = await uploadFile(file) const uploadedMedia = await uploadFile(file)
clearInterval(progressInterval) clearInterval(progressInterval)
uploadProgress = 100 uploadProgress = 100
// Brief delay to show completion // Brief delay to show completion
setTimeout(() => { setTimeout(() => {
value = uploadedMedia value = uploadedMedia
@ -136,7 +136,6 @@
isUploading = false isUploading = false
uploadProgress = 0 uploadProgress = 0
}, 500) }, 500)
} catch (err) { } catch (err) {
isUploading = false isUploading = false
uploadProgress = 0 uploadProgress = 0
@ -158,7 +157,7 @@
function handleDrop(event: DragEvent) { function handleDrop(event: DragEvent) {
event.preventDefault() event.preventDefault()
isDragOver = false isDragOver = false
const files = event.dataTransfer?.files const files = event.dataTransfer?.files
if (files) { if (files) {
handleFiles(files) handleFiles(files)
@ -188,7 +187,7 @@
// Update alt text on server // Update alt text on server
async function handleAltTextChange() { async function handleAltTextChange() {
if (!value) return if (!value) return
try { try {
const response = await authenticatedFetch(`/api/media/${value.id}/metadata`, { const response = await authenticatedFetch(`/api/media/${value.id}/metadata`, {
method: 'PATCH', method: 'PATCH',
@ -211,7 +210,7 @@
async function handleDescriptionChange() { async function handleDescriptionChange() {
if (!value) return if (!value) return
try { try {
const response = await authenticatedFetch(`/api/media/${value.id}/metadata`, { const response = await authenticatedFetch(`/api/media/${value.id}/metadata`, {
method: 'PATCH', method: 'PATCH',
@ -271,32 +270,51 @@
<!-- Compact Layout: Image and metadata side-by-side --> <!-- Compact Layout: Image and metadata side-by-side -->
<div class="compact-preview"> <div class="compact-preview">
<div class="compact-image"> <div class="compact-image">
<SmartImage <SmartImage
media={value} media={value}
alt={value?.altText || value?.filename || 'Uploaded image'} alt={value?.altText || value?.filename || 'Uploaded image'}
containerWidth={100} containerWidth={100}
loading="eager" loading="eager"
aspectRatio={aspectRatio} {aspectRatio}
class="preview-image" class="preview-image"
/> />
<!-- Overlay with actions --> <!-- Overlay with actions -->
<div class="preview-overlay"> <div class="preview-overlay">
<div class="preview-actions"> <div class="preview-actions">
<Button variant="overlay" buttonSize="small" onclick={handleBrowseClick}> <Button variant="overlay" buttonSize="small" onclick={handleBrowseClick}>
<RefreshIcon slot="icon" width="12" height="12" /> <RefreshIcon slot="icon" width="12" height="12" />
</Button> </Button>
<Button variant="overlay" buttonSize="small" onclick={handleRemove}> <Button variant="overlay" buttonSize="small" onclick={handleRemove}>
<svg slot="icon" width="12" height="12" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg
<polyline points="3,6 5,6 21,6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> slot="icon"
<path d="M19 6V20A2 2 0 0 1 17 22H7A2 2 0 0 1 5 20V6M8 6V4A2 2 0 0 1 10 2H14A2 2 0 0 1 16 4V6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<polyline
points="3,6 5,6 21,6"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M19 6V20A2 2 0 0 1 17 22H7A2 2 0 0 1 5 20V6M8 6V4A2 2 0 0 1 10 2H14A2 2 0 0 1 16 4V6"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg> </svg>
</Button> </Button>
</div> </div>
</div> </div>
</div> </div>
<div class="compact-info"> <div class="compact-info">
<!-- Alt Text Input in compact mode --> <!-- Alt Text Input in compact mode -->
{#if allowAltText} {#if allowAltText}
@ -309,7 +327,7 @@
buttonSize="small" buttonSize="small"
onblur={handleAltTextChange} onblur={handleAltTextChange}
/> />
<Input <Input
type="textarea" type="textarea"
label="Description (Optional)" label="Description (Optional)"
@ -326,15 +344,15 @@
{:else} {:else}
<!-- Standard Layout: Image preview --> <!-- Standard Layout: Image preview -->
<div class="image-preview" style={aspectRatioStyle}> <div class="image-preview" style={aspectRatioStyle}>
<SmartImage <SmartImage
media={value} media={value}
alt={value?.altText || value?.filename || 'Uploaded image'} alt={value?.altText || value?.filename || 'Uploaded image'}
containerWidth={800} containerWidth={800}
loading="eager" loading="eager"
aspectRatio={aspectRatio} {aspectRatio}
class="preview-image" class="preview-image"
/> />
<!-- Overlay with actions --> <!-- Overlay with actions -->
<div class="preview-overlay"> <div class="preview-overlay">
<div class="preview-actions"> <div class="preview-actions">
@ -342,11 +360,30 @@
<RefreshIcon slot="icon" width="16" height="16" /> <RefreshIcon slot="icon" width="16" height="16" />
Replace Replace
</Button> </Button>
<Button variant="overlay" buttonSize="small" onclick={handleRemove}> <Button variant="overlay" buttonSize="small" onclick={handleRemove}>
<svg slot="icon" width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg
<polyline points="3,6 5,6 21,6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> slot="icon"
<path d="M19 6V20A2 2 0 0 1 17 22H7A2 2 0 0 1 5 20V6M8 6V4A2 2 0 0 1 10 2H14A2 2 0 0 1 16 4V6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<polyline
points="3,6 5,6 21,6"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M19 6V20A2 2 0 0 1 17 22H7A2 2 0 0 1 5 20V6M8 6V4A2 2 0 0 1 10 2H14A2 2 0 0 1 16 4V6"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg> </svg>
Remove Remove
</Button> </Button>
@ -365,10 +402,9 @@
</p> </p>
</div> </div>
{/if} {/if}
{:else} {:else}
<!-- Upload Drop Zone --> <!-- Upload Drop Zone -->
<div <div
class="drop-zone" class="drop-zone"
class:drag-over={isDragOver} class:drag-over={isDragOver}
class:uploading={isUploading} class:uploading={isUploading}
@ -412,12 +448,53 @@
{:else} {:else}
<!-- Upload Prompt --> <!-- Upload Prompt -->
<div class="upload-prompt"> <div class="upload-prompt">
<svg class="upload-icon" width="48" height="48" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg
<path d="M14 2H6A2 2 0 0 0 4 4V20A2 2 0 0 0 6 22H18A2 2 0 0 0 20 20V8L14 2Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> class="upload-icon"
<polyline points="14,2 14,8 20,8" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> width="48"
<line x1="16" y1="13" x2="8" y2="13" stroke="currentColor" stroke-width="2" stroke-linecap="round"/> height="48"
<line x1="16" y1="17" x2="8" y2="17" stroke="currentColor" stroke-width="2" stroke-linecap="round"/> viewBox="0 0 24 24"
<polyline points="10,9 9,9 8,9" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M14 2H6A2 2 0 0 0 4 4V20A2 2 0 0 0 6 22H18A2 2 0 0 0 20 20V8L14 2Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<polyline
points="14,2 14,8 20,8"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<line
x1="16"
y1="13"
x2="8"
y2="13"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
<line
x1="16"
y1="17"
x2="8"
y2="17"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
<polyline
points="10,9 9,9 8,9"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg> </svg>
<p class="upload-main-text">{placeholder}</p> <p class="upload-main-text">{placeholder}</p>
<p class="upload-sub-text"> <p class="upload-sub-text">
@ -432,14 +509,10 @@
<!-- Action Buttons --> <!-- Action Buttons -->
{#if !hasValue && !isUploading} {#if !hasValue && !isUploading}
<div class="action-buttons"> <div class="action-buttons">
<Button variant="primary" onclick={handleBrowseClick}> <Button variant="primary" onclick={handleBrowseClick}>Choose File</Button>
Choose File
</Button>
{#if showBrowseLibrary} {#if showBrowseLibrary}
<Button variant="ghost" onclick={handleBrowseLibrary}> <Button variant="ghost" onclick={handleBrowseLibrary}>Browse Library</Button>
Browse Library
</Button>
{/if} {/if}
</div> </div>
{/if} {/if}
@ -455,7 +528,7 @@
helpText="Help make your content accessible. Describe what's in the image." helpText="Help make your content accessible. Describe what's in the image."
onblur={handleAltTextChange} onblur={handleAltTextChange}
/> />
<Input <Input
type="textarea" type="textarea"
label="Description (Optional)" label="Description (Optional)"
@ -498,7 +571,7 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: $unit-2x; gap: $unit-2x;
&.compact { &.compact {
gap: $unit; gap: $unit;
} }
@ -780,4 +853,4 @@
flex-direction: column; flex-direction: column;
} }
} }
</style> </style>

View file

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

View file

@ -52,40 +52,40 @@
// Computed properties // Computed properties
const hasValue = $derived( const hasValue = $derived(
mode === 'single' mode === 'single'
? value !== null && value !== undefined ? value !== null && value !== undefined
: Array.isArray(value) && value.length > 0 : Array.isArray(value) && value.length > 0
) )
const displayText = $derived( const displayText = $derived(
!hasValue !hasValue
? placeholder ? placeholder
: mode === 'single' && value && !Array.isArray(value) : mode === 'single' && value && !Array.isArray(value)
? value.filename ? value.filename
: mode === 'multiple' && Array.isArray(value) : mode === 'multiple' && Array.isArray(value)
? value.length === 1 ? value.length === 1
? `${value.length} file selected` ? `${value.length} file selected`
: `${value.length} files selected` : `${value.length} files selected`
: placeholder : placeholder
) )
const selectedIds = $derived( const selectedIds = $derived(
!hasValue !hasValue
? [] ? []
: mode === 'single' && value && !Array.isArray(value) : mode === 'single' && value && !Array.isArray(value)
? [value.id] ? [value.id]
: mode === 'multiple' && Array.isArray(value) : mode === 'multiple' && Array.isArray(value)
? value.map(item => item.id) ? value.map((item) => item.id)
: [] : []
) )
const modalTitle = $derived( const modalTitle = $derived(
mode === 'single' ? `Select ${fileType === 'image' ? 'Image' : 'Media'}` : `Select ${fileType === 'image' ? 'Images' : 'Media'}` mode === 'single'
? `Select ${fileType === 'image' ? 'Image' : 'Media'}`
: `Select ${fileType === 'image' ? 'Images' : 'Media'}`
) )
const confirmText = $derived( const confirmText = $derived(mode === 'single' ? 'Select' : 'Select Files')
mode === 'single' ? 'Select' : 'Select Files'
)
</script> </script>
<div class="media-input"> <div class="media-input">
@ -106,10 +106,29 @@
<img src={value.thumbnailUrl} alt={value.filename} /> <img src={value.thumbnailUrl} alt={value.filename} />
{:else} {:else}
<div class="media-placeholder"> <div class="media-placeholder">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg
<rect x="3" y="5" width="18" height="14" rx="2" stroke="currentColor" stroke-width="2"/> width="24"
<circle cx="8.5" cy="8.5" r=".5" fill="currentColor"/> height="24"
<path d="M3 16l5-5 3 3 4-4 4 4" stroke="currentColor" stroke-width="2" fill="none"/> viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect
x="3"
y="5"
width="18"
height="14"
rx="2"
stroke="currentColor"
stroke-width="2"
/>
<circle cx="8.5" cy="8.5" r=".5" fill="currentColor" />
<path
d="M3 16l5-5 3 3 4-4 4 4"
stroke="currentColor"
stroke-width="2"
fill="none"
/>
</svg> </svg>
</div> </div>
{/if} {/if}
@ -133,10 +152,29 @@
<img src={item.thumbnailUrl} alt={item.filename} /> <img src={item.thumbnailUrl} alt={item.filename} />
{:else} {:else}
<div class="media-placeholder"> <div class="media-placeholder">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg
<rect x="3" y="5" width="18" height="14" rx="2" stroke="currentColor" stroke-width="2"/> width="16"
<circle cx="8.5" cy="8.5" r=".5" fill="currentColor"/> height="16"
<path d="M3 16l5-5 3 3 4-4 4 4" stroke="currentColor" stroke-width="2" fill="none"/> viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect
x="3"
y="5"
width="18"
height="14"
rx="2"
stroke="currentColor"
stroke-width="2"
/>
<circle cx="8.5" cy="8.5" r=".5" fill="currentColor" />
<path
d="M3 16l5-5 3 3 4-4 4 4"
stroke="currentColor"
stroke-width="2"
fill="none"
/>
</svg> </svg>
</div> </div>
{/if} {/if}
@ -168,9 +206,7 @@
class:placeholder={!hasValue} class:placeholder={!hasValue}
/> />
<div class="input-actions"> <div class="input-actions">
<Button variant="ghost" onclick={openModal}> <Button variant="ghost" onclick={openModal}>Browse</Button>
Browse
</Button>
{#if hasValue} {#if hasValue}
<Button variant="ghost" onclick={handleClear} aria-label="Clear selection"> <Button variant="ghost" onclick={handleClear} aria-label="Clear selection">
<svg <svg
@ -205,7 +241,7 @@
{fileType} {fileType}
{selectedIds} {selectedIds}
title={modalTitle} title={modalTitle}
confirmText={confirmText} {confirmText}
onselect={handleMediaSelect} onselect={handleMediaSelect}
/> />
</div> </div>
@ -349,7 +385,7 @@
background: transparent; background: transparent;
font-size: 0.875rem; font-size: 0.875rem;
color: $grey-10; color: $grey-10;
&:focus { &:focus {
outline: none; outline: none;
} }
@ -391,4 +427,4 @@
flex-wrap: wrap; flex-wrap: wrap;
} }
} }
</style> </style>

View file

@ -41,7 +41,7 @@
} else { } else {
onSelect(selectedMedia) onSelect(selectedMedia)
} }
handleClose() handleClose()
} }
@ -59,8 +59,10 @@
const canConfirm = $derived(selectedMedia.length > 0) const canConfirm = $derived(selectedMedia.length > 0)
const selectionCount = $derived(selectedMedia.length) const selectionCount = $derived(selectedMedia.length)
const footerText = $derived( const footerText = $derived(
mode === 'single' mode === 'single'
? canConfirm ? '1 item selected' : 'No item selected' ? canConfirm
? '1 item selected'
: 'No item selected'
: `${selectionCount} item${selectionCount !== 1 ? 's' : ''} selected` : `${selectionCount} item${selectionCount !== 1 ? 's' : ''} selected`
) )
</script> </script>
@ -117,14 +119,8 @@
<span class="selection-count">{footerText}</span> <span class="selection-count">{footerText}</span>
</div> </div>
<div class="footer-actions"> <div class="footer-actions">
<Button variant="ghost" onclick={handleCancel} disabled={isLoading}> <Button variant="ghost" onclick={handleCancel} disabled={isLoading}>Cancel</Button>
Cancel <Button variant="primary" onclick={handleConfirm} disabled={!canConfirm || isLoading}>
</Button>
<Button
variant="primary"
onclick={handleConfirm}
disabled={!canConfirm || isLoading}
>
{confirmText} {confirmText}
</Button> </Button>
</div> </div>
@ -226,4 +222,4 @@
font-size: 1.25rem; font-size: 1.25rem;
} }
} }
</style> </style>

View file

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

View file

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

View file

@ -132,16 +132,12 @@
<div class="popover-content"> <div class="popover-content">
<h3>Post Settings</h3> <h3>Post Settings</h3>
<Input <Input label="Slug" bind:value={slug} placeholder="post-slug" />
label="Slug"
bind:value={slug}
placeholder="post-slug"
/>
{#if postType === 'essay'} {#if postType === 'essay'}
<Input <Input
type="textarea" type="textarea"
label="Excerpt" label="Excerpt"
bind:value={excerpt} bind:value={excerpt}
rows={3} rows={3}
placeholder="Brief description..." placeholder="Brief description..."
@ -149,14 +145,14 @@
{/if} {/if}
<div class="tags-section"> <div class="tags-section">
<Input <Input
label="Tags" label="Tags"
bind:value={tagInput} bind:value={tagInput}
onkeydown={(e) => e.key === 'Enter' && (e.preventDefault(), onAddTag())} onkeydown={(e) => e.key === 'Enter' && (e.preventDefault(), onAddTag())}
placeholder="Add tags..." placeholder="Add tags..."
/> />
<button type="button" onclick={onAddTag} class="add-tag-btn">Add</button> <button type="button" onclick={onAddTag} class="add-tag-btn">Add</button>
{#if tags.length > 0} {#if tags.length > 0}
<div class="tags"> <div class="tags">
{#each tags as tag} {#each tags as tag}

View file

@ -61,7 +61,7 @@
function handleFeaturedImageUpload(media: Media) { function handleFeaturedImageUpload(media: Media) {
featuredImage = media featuredImage = media
// If no title is set, use the media filename as a starting point // If no title is set, use the media filename as a starting point
if (!title.trim() && media.originalName) { if (!title.trim() && media.originalName) {
title = media.originalName.replace(/\.[^/.]+$/, '') // Remove file extension title = media.originalName.replace(/\.[^/.]+$/, '') // Remove file extension
@ -119,7 +119,12 @@
status, status,
content: editorContent, content: editorContent,
featuredImage: featuredImage.url, featuredImage: featuredImage.url,
tags: tags ? tags.split(',').map(tag => tag.trim()).filter(Boolean) : [], tags: tags
? tags
.split(',')
.map((tag) => tag.trim())
.filter(Boolean)
: [],
excerpt: generateExcerpt(editorContent) excerpt: generateExcerpt(editorContent)
} }
@ -147,7 +152,6 @@
} else { } else {
goto('/admin/posts') goto('/admin/posts')
} }
} catch (err) { } catch (err) {
error = `Failed to ${mode === 'edit' ? 'update' : 'create'} photo post` error = `Failed to ${mode === 'edit' ? 'update' : 'create'} photo post`
console.error(err) console.error(err)
@ -159,7 +163,7 @@
function generateExcerpt(content: JSONContent): string { function generateExcerpt(content: JSONContent): string {
// Extract plain text from editor content for excerpt // Extract plain text from editor content for excerpt
if (!content?.content) return '' if (!content?.content) return ''
let text = '' let text = ''
const extractText = (node: any) => { const extractText = (node: any) => {
if (node.type === 'text') { if (node.type === 'text') {
@ -168,7 +172,7 @@
node.content.forEach(extractText) node.content.forEach(extractText)
} }
} }
content.content.forEach(extractText) content.content.forEach(extractText)
return text.substring(0, 200) + (text.length > 200 ? '...' : '') return text.substring(0, 200) + (text.length > 200 ? '...' : '')
} }
@ -190,16 +194,22 @@
<h1>{mode === 'edit' ? 'Edit Photo Post' : 'New Photo Post'}</h1> <h1>{mode === 'edit' ? 'Edit Photo Post' : 'New Photo Post'}</h1>
<p class="subtitle">Share a photo with a caption and description</p> <p class="subtitle">Share a photo with a caption and description</p>
</div> </div>
<div class="header-actions"> <div class="header-actions">
{#if !isSaving} {#if !isSaving}
<Button variant="ghost" onclick={() => goto('/admin/posts')}> <Button variant="ghost" onclick={() => goto('/admin/posts')}>Cancel</Button>
Cancel <Button
</Button> variant="secondary"
<Button variant="secondary" onclick={handleDraft} disabled={!featuredImage || !title.trim()}> onclick={handleDraft}
disabled={!featuredImage || !title.trim()}
>
Save Draft Save Draft
</Button> </Button>
<Button variant="primary" onclick={handlePublish} disabled={!featuredImage || !title.trim()}> <Button
variant="primary"
onclick={handlePublish}
disabled={!featuredImage || !title.trim()}
>
{isSaving ? 'Publishing...' : 'Publish'} {isSaving ? 'Publishing...' : 'Publish'}
</Button> </Button>
{/if} {/if}
@ -352,4 +362,4 @@
min-height: 200px; min-height: 200px;
} }
} }
</style> </style>

View file

@ -114,14 +114,14 @@
<p class="post-preview">{getPostSnippet(post)}</p> <p class="post-preview">{getPostSnippet(post)}</p>
</div> </div>
<AdminByline <AdminByline
sections={[ sections={[
postTypeLabels[post.postType] || post.postType, postTypeLabels[post.postType] || post.postType,
post.status === 'published' ? 'Published' : 'Draft', post.status === 'published' ? 'Published' : 'Draft',
post.status === 'published' && post.publishedAt post.status === 'published' && post.publishedAt
? `published ${formatDate(post.publishedAt)}` ? `published ${formatDate(post.publishedAt)}`
: `created ${formatDate(post.createdAt)}` : `created ${formatDate(post.createdAt)}`
]} ]}
/> />
</article> </article>
@ -180,4 +180,4 @@
padding: $unit-2x; padding: $unit-2x;
} }
} }
</style> </style>

View file

@ -48,13 +48,17 @@
label: 'Slug', label: 'Slug',
placeholder: 'post-slug' placeholder: 'post-slug'
}, },
...(postType === 'essay' ? [{ ...(postType === 'essay'
type: 'textarea' as const, ? [
key: 'excerpt', {
label: 'Excerpt', type: 'textarea' as const,
rows: 3, key: 'excerpt',
placeholder: 'Brief description...' label: 'Excerpt',
}] : []), rows: 3,
placeholder: 'Brief description...'
}
]
: []),
{ {
type: 'tags', type: 'tags',
key: 'tags', key: 'tags',
@ -97,7 +101,7 @@
}) })
</script> </script>
<GenericMetadataPopover <GenericMetadataPopover
{config} {config}
bind:data={popoverData} bind:data={popoverData}
{triggerElement} {triggerElement}
@ -105,4 +109,4 @@
{onAddTag} {onAddTag}
{onRemoveTag} {onRemoveTag}
{onClose} {onClose}
/> />

View file

@ -9,10 +9,10 @@
} }
let { formData = $bindable() }: Props = $props() let { formData = $bindable() }: Props = $props()
// Convert logoUrl string to Media object for ImageUploader // Convert logoUrl string to Media object for ImageUploader
let logoMedia = $state<Media | null>(null) let logoMedia = $state<Media | null>(null)
// Update logoMedia when logoUrl changes // Update logoMedia when logoUrl changes
$effect(() => { $effect(() => {
if (formData.logoUrl && !logoMedia) { if (formData.logoUrl && !logoMedia) {
@ -37,12 +37,12 @@
logoMedia = null logoMedia = null
} }
}) })
function handleLogoUpload(media: Media) { function handleLogoUpload(media: Media) {
formData.logoUrl = media.url formData.logoUrl = media.url
logoMedia = media logoMedia = media
} }
function handleLogoRemove() { function handleLogoRemove() {
formData.logoUrl = '' formData.logoUrl = ''
logoMedia = null logoMedia = null

View file

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

View file

@ -8,10 +8,10 @@
} }
let { formData = $bindable() }: Props = $props() let { formData = $bindable() }: Props = $props()
// Convert gallery array to Media objects for GalleryUploader // Convert gallery array to Media objects for GalleryUploader
let galleryMedia = $state<Media[]>([]) let galleryMedia = $state<Media[]>([])
// Update galleryMedia when gallery changes // Update galleryMedia when gallery changes
$effect(() => { $effect(() => {
if (formData.gallery && Array.isArray(formData.gallery)) { if (formData.gallery && Array.isArray(formData.gallery)) {
@ -44,13 +44,13 @@
galleryMedia = [] galleryMedia = []
} }
}) })
function handleGalleryUpload(media: Media[]) { function handleGalleryUpload(media: Media[]) {
// Store as Media objects in the gallery field // Store as Media objects in the gallery field
formData.gallery = media formData.gallery = media
galleryMedia = media galleryMedia = media
} }
function handleGalleryReorder(media: Media[]) { function handleGalleryReorder(media: Media[]) {
formData.gallery = media formData.gallery = media
galleryMedia = media galleryMedia = media
@ -59,7 +59,7 @@
<div class="form-section"> <div class="form-section">
<h2>Project Gallery</h2> <h2>Project Gallery</h2>
<GalleryUploader <GalleryUploader
label="Gallery Images" label="Gallery Images"
value={galleryMedia} value={galleryMedia}
@ -89,4 +89,4 @@
color: $grey-10; color: $grey-10;
} }
} }
</style> </style>

View file

@ -39,19 +39,19 @@
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000) const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000)
if (diffInSeconds < 60) return 'just now' if (diffInSeconds < 60) return 'just now'
const minutes = Math.floor(diffInSeconds / 60) const minutes = Math.floor(diffInSeconds / 60)
if (diffInSeconds < 3600) return `${minutes} ${minutes === 1 ? 'minute' : 'minutes'} ago` if (diffInSeconds < 3600) return `${minutes} ${minutes === 1 ? 'minute' : 'minutes'} ago`
const hours = Math.floor(diffInSeconds / 3600) const hours = Math.floor(diffInSeconds / 3600)
if (diffInSeconds < 86400) return `${hours} ${hours === 1 ? 'hour' : 'hours'} ago` if (diffInSeconds < 86400) return `${hours} ${hours === 1 ? 'hour' : 'hours'} ago`
const days = Math.floor(diffInSeconds / 86400) const days = Math.floor(diffInSeconds / 86400)
if (diffInSeconds < 2592000) return `${days} ${days === 1 ? 'day' : 'days'} ago` if (diffInSeconds < 2592000) return `${days} ${days === 1 ? 'day' : 'days'} ago`
const months = Math.floor(diffInSeconds / 2592000) const months = Math.floor(diffInSeconds / 2592000)
if (diffInSeconds < 31536000) return `${months} ${months === 1 ? 'month' : 'months'} ago` if (diffInSeconds < 31536000) return `${months} ${months === 1 ? 'month' : 'months'} ago`
const years = Math.floor(diffInSeconds / 31536000) const years = Math.floor(diffInSeconds / 31536000)
return `${years} ${years === 1 ? 'year' : 'years'} ago` return `${years} ${years === 1 ? 'year' : 'years'} ago`
} }
@ -81,11 +81,10 @@
function handleCloseDropdowns() { function handleCloseDropdowns() {
isDropdownOpen = false isDropdownOpen = false
} }
document.addEventListener('closeDropdowns', handleCloseDropdowns) document.addEventListener('closeDropdowns', handleCloseDropdowns)
return () => document.removeEventListener('closeDropdowns', handleCloseDropdowns) return () => document.removeEventListener('closeDropdowns', handleCloseDropdowns)
}) })
</script> </script>
<div <div
@ -103,23 +102,19 @@
<div class="project-info"> <div class="project-info">
<h3 class="project-title">{project.title}</h3> <h3 class="project-title">{project.title}</h3>
<AdminByline <AdminByline
sections={[ sections={[
project.projectType === 'work' ? 'Work' : 'Labs', project.projectType === 'work' ? 'Work' : 'Labs',
project.status === 'published' ? 'Published' : 'Draft', project.status === 'published' ? 'Published' : 'Draft',
project.status === 'published' && project.publishedAt project.status === 'published' && project.publishedAt
? `Published ${formatRelativeTime(project.publishedAt)}` ? `Published ${formatRelativeTime(project.publishedAt)}`
: `Created ${formatRelativeTime(project.createdAt)}` : `Created ${formatRelativeTime(project.createdAt)}`
]} ]}
/> />
</div> </div>
<div class="dropdown-container"> <div class="dropdown-container">
<button <button class="action-button" onclick={handleToggleDropdown} aria-label="Project actions">
class="action-button"
onclick={handleToggleDropdown}
aria-label="Project actions"
>
<svg <svg
width="20" width="20"
height="20" height="20"
@ -201,7 +196,6 @@
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.dropdown-container { .dropdown-container {
position: relative; position: relative;
flex-shrink: 0; flex-shrink: 0;
@ -265,5 +259,4 @@
background-color: $grey-90; background-color: $grey-90;
margin: $unit-half 0; margin: $unit-half 0;
} }
</style> </style>

View file

@ -51,7 +51,7 @@
font-weight: 600; font-weight: 600;
margin: 0 0 $unit-3x; margin: 0 0 $unit-3x;
color: $grey-10; color: $grey-10;
} }
} }
.form-row { .form-row {

View file

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

View file

@ -42,7 +42,7 @@
}) })
const charCount = $derived(textContent().length) const charCount = $derived(textContent().length)
const isOverLimit = $derived(charCount > maxLength) const isOverLimit = $derived(charCount > maxLength)
// Check if form has content // Check if form has content
const hasContent = $derived(() => { const hasContent = $derived(() => {
// For posts, check if either content exists or it's a link with URL // For posts, check if either content exists or it's a link with URL
@ -229,7 +229,6 @@
} }
} }
.composer-container { .composer-container {
max-width: 600px; max-width: 600px;
margin: 0 auto; margin: 0 auto;

View file

@ -60,9 +60,7 @@
}) })
const availableActions = $derived( const availableActions = $derived(
dropdownActions.filter(action => dropdownActions.filter((action) => action.show !== false && action.status !== currentStatus)
action.show !== false && action.status !== currentStatus
)
) )
</script> </script>
@ -75,7 +73,7 @@
> >
{isLoading ? `${primaryAction.label.replace(/e$/, 'ing')}...` : primaryAction.label} {isLoading ? `${primaryAction.label.replace(/e$/, 'ing')}...` : primaryAction.label}
</Button> </Button>
{#if availableActions.length > 0} {#if availableActions.length > 0}
<Button <Button
variant="ghost" variant="ghost"
@ -94,7 +92,7 @@
/> />
</svg> </svg>
</Button> </Button>
{#if isDropdownOpen} {#if isDropdownOpen}
<DropdownMenuContainer> <DropdownMenuContainer>
{#each availableActions as action} {#each availableActions as action}
@ -115,4 +113,4 @@
display: flex; display: flex;
gap: $unit-half; gap: $unit-half;
} }
</style> </style>

View file

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

View file

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

View file

@ -54,4 +54,4 @@ export const GalleryPlaceholder = (
} }
} }
} }
}) })

View file

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

View file

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

View file

@ -5,7 +5,7 @@
import Upload from 'lucide-svelte/icons/upload' import Upload from 'lucide-svelte/icons/upload'
import { NodeViewWrapper } from 'svelte-tiptap' import { NodeViewWrapper } from 'svelte-tiptap'
import MediaLibraryModal from '../../../admin/MediaLibraryModal.svelte' import MediaLibraryModal from '../../../admin/MediaLibraryModal.svelte'
const { editor, deleteNode }: NodeViewProps = $props() const { editor, deleteNode }: NodeViewProps = $props()
let isMediaLibraryOpen = $state(false) let isMediaLibraryOpen = $state(false)
@ -27,7 +27,7 @@
function handleMediaSelect(media: Media | Media[]) { function handleMediaSelect(media: Media | Media[]) {
const mediaArray = Array.isArray(media) ? media : [media] const mediaArray = Array.isArray(media) ? media : [media]
if (mediaArray.length > 0) { if (mediaArray.length > 0) {
const galleryImages = mediaArray.map(m => ({ const galleryImages = mediaArray.map((m) => ({
id: m.id, id: m.id,
url: m.url, url: m.url,
alt: m.altText || '', alt: m.altText || '',
@ -128,7 +128,7 @@
<Upload class="edra-gallery-placeholder-icon" /> <Upload class="edra-gallery-placeholder-icon" />
<span class="edra-gallery-placeholder-text">Upload Images</span> <span class="edra-gallery-placeholder-text">Upload Images</span>
</button> </button>
<button <button
class="edra-gallery-placeholder-option" class="edra-gallery-placeholder-option"
onclick={handleBrowseLibrary} onclick={handleBrowseLibrary}
@ -225,8 +225,12 @@
} }
@keyframes spin { @keyframes spin {
0% { transform: rotate(0deg); } 0% {
100% { transform: rotate(360deg); } transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
} }
:global(.edra-gallery-placeholder-icon) { :global(.edra-gallery-placeholder-icon) {
@ -240,4 +244,4 @@
color: #6b7280; color: #6b7280;
font-weight: 500; font-weight: 500;
} }
</style> </style>

View file

@ -6,7 +6,7 @@
import { NodeViewWrapper } from 'svelte-tiptap' import { NodeViewWrapper } from 'svelte-tiptap'
import MediaLibraryModal from '../../../admin/MediaLibraryModal.svelte' import MediaLibraryModal from '../../../admin/MediaLibraryModal.svelte'
import { onMount } from 'svelte' import { onMount } from 'svelte'
const { editor, deleteNode }: NodeViewProps = $props() const { editor, deleteNode }: NodeViewProps = $props()
let isMediaLibraryOpen = $state(false) let isMediaLibraryOpen = $state(false)
@ -28,11 +28,15 @@
function handleMediaSelect(media: Media | Media[]) { function handleMediaSelect(media: Media | Media[]) {
const selectedMedia = Array.isArray(media) ? media[0] : media const selectedMedia = Array.isArray(media) ? media[0] : media
if (selectedMedia) { if (selectedMedia) {
editor.chain().focus().setImage({ editor
src: selectedMedia.url, .chain()
alt: selectedMedia.altText || '', .focus()
title: selectedMedia.description || '' .setImage({
}).run() src: selectedMedia.url,
alt: selectedMedia.altText || '',
title: selectedMedia.description || ''
})
.run()
} }
isMediaLibraryOpen = false isMediaLibraryOpen = false
} }
@ -74,11 +78,15 @@
if (response.ok) { if (response.ok) {
const media = await response.json() const media = await response.json()
editor.chain().focus().setImage({ editor
src: media.url, .chain()
alt: media.altText || '', .focus()
title: media.description || '' .setImage({
}).run() src: media.url,
alt: media.altText || '',
title: media.description || ''
})
.run()
} else { } else {
console.error('Failed to upload image:', response.status) console.error('Failed to upload image:', response.status)
alert('Failed to upload image. Please try again.') alert('Failed to upload image. Please try again.')
@ -123,7 +131,7 @@
<Upload class="edra-media-placeholder-icon" /> <Upload class="edra-media-placeholder-icon" />
<span class="edra-media-placeholder-text">Upload Image</span> <span class="edra-media-placeholder-text">Upload Image</span>
</button> </button>
<button <button
class="edra-media-placeholder-option" class="edra-media-placeholder-option"
onclick={handleBrowseLibrary} onclick={handleBrowseLibrary}
@ -219,8 +227,12 @@
} }
@keyframes spin { @keyframes spin {
0% { transform: rotate(0deg); } 0% {
100% { transform: rotate(360deg); } transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
} }
:global(.edra-media-placeholder-icon) { :global(.edra-media-placeholder-icon) {

View file

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

View file

@ -1,37 +1,39 @@
import { z } from 'zod' import { z } from 'zod'
export const projectSchema = z.object({ export const projectSchema = z
title: z.string().min(1, 'Title is required'), .object({
description: z.string().optional(), title: z.string().min(1, 'Title is required'),
year: z description: z.string().optional(),
.number() year: z
.min(1990) .number()
.max(new Date().getFullYear() + 1), .min(1990)
client: z.string().optional(), .max(new Date().getFullYear() + 1),
externalUrl: z.string().url().optional().or(z.literal('')), client: z.string().optional(),
backgroundColor: z externalUrl: z.string().url().optional().or(z.literal('')),
.string() backgroundColor: z
.regex(/^#[0-9A-Fa-f]{6}$/) .string()
.optional() .regex(/^#[0-9A-Fa-f]{6}$/)
.or(z.literal('')), .optional()
highlightColor: z .or(z.literal('')),
.string() highlightColor: z
.regex(/^#[0-9A-Fa-f]{6}$/) .string()
.optional() .regex(/^#[0-9A-Fa-f]{6}$/)
.or(z.literal('')), .optional()
status: z.enum(['draft', 'published', 'list-only', 'password-protected']), .or(z.literal('')),
password: z.string().optional() status: z.enum(['draft', 'published', 'list-only', 'password-protected']),
}).refine( password: z.string().optional()
(data) => { })
if (data.status === 'password-protected') { .refine(
return data.password && data.password.trim().length > 0 (data) => {
if (data.status === 'password-protected') {
return data.password && data.password.trim().length > 0
}
return true
},
{
message: 'Password is required when status is password-protected',
path: ['password']
} }
return true )
},
{
message: 'Password is required when status is password-protected',
path: ['password']
}
)
export type ProjectSchema = z.infer<typeof projectSchema> export type ProjectSchema = z.infer<typeof projectSchema>

View file

@ -216,14 +216,10 @@ export function getResponsiveUrls(publicId: string): Record<string, string> {
} }
// Smart image size selection based on container width // Smart image size selection based on container width
export function getSmartImageUrl( export function getSmartImageUrl(publicId: string, containerWidth: number, retina = true): string {
publicId: string,
containerWidth: number,
retina = true
): string {
// Account for retina displays // Account for retina displays
const targetWidth = retina ? containerWidth * 2 : containerWidth const targetWidth = retina ? containerWidth * 2 : containerWidth
// Select appropriate size // Select appropriate size
if (targetWidth <= 600) { if (targetWidth <= 600) {
return getOptimizedUrl(publicId, { width: imageSizes.small.width }) return getOptimizedUrl(publicId, { width: imageSizes.small.width })

View file

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

View file

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

View file

@ -23,7 +23,7 @@
}) })
const currentPath = $derived($page.url.pathname) const currentPath = $derived($page.url.pathname)
// Pages that should use the card metaphor (no .admin-content wrapper) // Pages that should use the card metaphor (no .admin-content wrapper)
const cardLayoutPages = ['/admin'] const cardLayoutPages = ['/admin']
const useCardLayout = $derived(cardLayoutPages.includes(currentPath)) const useCardLayout = $derived(cardLayoutPages.includes(currentPath))

View file

@ -5,4 +5,4 @@
onMount(() => { onMount(() => {
goto('/admin/projects', { replaceState: true }) goto('/admin/projects', { replaceState: true })
}) })
</script> </script>

View file

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

View file

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

View file

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

View file

@ -30,22 +30,42 @@
<div class="button-group"> <div class="button-group">
<Button buttonSize="small" iconOnly> <Button buttonSize="small" iconOnly>
<svg slot="icon" width="16" height="16" viewBox="0 0 16 16" fill="none"> <svg slot="icon" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M8 4v8m4-4H4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/> <path
d="M8 4v8m4-4H4"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
/>
</svg> </svg>
</Button> </Button>
<Button buttonSize="medium" iconOnly> <Button buttonSize="medium" iconOnly>
<svg slot="icon" width="18" height="18" viewBox="0 0 18 18" fill="none"> <svg slot="icon" width="18" height="18" viewBox="0 0 18 18" fill="none">
<path d="M9 5v8m4-4H5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/> <path
d="M9 5v8m4-4H5"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
/>
</svg> </svg>
</Button> </Button>
<Button buttonSize="large" iconOnly> <Button buttonSize="large" iconOnly>
<svg slot="icon" width="20" height="20" viewBox="0 0 20 20" fill="none"> <svg slot="icon" width="20" height="20" viewBox="0 0 20 20" fill="none">
<path d="M10 6v8m4-4H6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/> <path
d="M10 6v8m4-4H6"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
/>
</svg> </svg>
</Button> </Button>
<Button buttonSize="icon" iconOnly variant="ghost"> <Button buttonSize="icon" iconOnly variant="ghost">
<svg slot="icon" width="18" height="18" viewBox="0 0 18 18" fill="none"> <svg slot="icon" width="18" height="18" viewBox="0 0 18 18" fill="none">
<path d="M6 6l6 6m0-6l-6 6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/> <path
d="M6 6l6 6m0-6l-6 6"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
/>
</svg> </svg>
</Button> </Button>
</div> </div>
@ -56,14 +76,25 @@
<div class="button-group"> <div class="button-group">
<Button> <Button>
<svg slot="icon" width="16" height="16" viewBox="0 0 16 16" fill="none"> <svg slot="icon" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M8 4v8m4-4H4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/> <path
d="M8 4v8m4-4H4"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
/>
</svg> </svg>
Add Item Add Item
</Button> </Button>
<Button iconPosition="right" variant="secondary"> <Button iconPosition="right" variant="secondary">
Next Next
<svg slot="icon" width="16" height="16" viewBox="0 0 16 16" fill="none"> <svg slot="icon" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M6 4l4 4-4 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> <path
d="M6 4l4 4-4 4"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg> </svg>
</Button> </Button>
</div> </div>
@ -121,4 +152,4 @@
gap: $unit-2x; gap: $unit-2x;
flex-wrap: wrap; flex-wrap: wrap;
} }
</style> </style>

View file

@ -71,7 +71,7 @@
<section class="test-section"> <section class="test-section">
<h2>MediaInput Component</h2> <h2>MediaInput Component</h2>
<p>Generic input component for media selection with preview.</p> <p>Generic input component for media selection with preview.</p>
<div class="form-grid"> <div class="form-grid">
<MediaInput <MediaInput
label="Single Media File" label="Single Media File"
@ -104,7 +104,7 @@
<section class="test-section"> <section class="test-section">
<h2>ImagePicker Component</h2> <h2>ImagePicker Component</h2>
<p>Specialized image picker with enhanced preview and aspect ratio support.</p> <p>Specialized image picker with enhanced preview and aspect ratio support.</p>
<div class="form-grid"> <div class="form-grid">
<ImagePicker <ImagePicker
label="Featured Image" label="Featured Image"
@ -128,13 +128,9 @@
<section class="test-section"> <section class="test-section">
<h2>GalleryManager Component</h2> <h2>GalleryManager Component</h2>
<p>Multiple image management with drag-and-drop reordering.</p> <p>Multiple image management with drag-and-drop reordering.</p>
<div class="form-column"> <div class="form-column">
<GalleryManager <GalleryManager label="Image Gallery" bind:value={galleryImages} showFileInfo={false} />
label="Image Gallery"
bind:value={galleryImages}
showFileInfo={false}
/>
<GalleryManager <GalleryManager
label="Project Gallery (Max 6 images)" label="Project Gallery (Max 6 images)"
@ -149,12 +145,8 @@
<section class="test-section"> <section class="test-section">
<h2>Form Actions</h2> <h2>Form Actions</h2>
<div class="actions-grid"> <div class="actions-grid">
<Button variant="primary" onclick={logAllValues}> <Button variant="primary" onclick={logAllValues}>Log All Values</Button>
Log All Values <Button variant="ghost" onclick={clearAllValues}>Clear All</Button>
</Button>
<Button variant="ghost" onclick={clearAllValues}>
Clear All
</Button>
</div> </div>
</section> </section>
@ -166,25 +158,37 @@
<h4>Single Media:</h4> <h4>Single Media:</h4>
<pre>{JSON.stringify(singleMedia?.filename || null, null, 2)}</pre> <pre>{JSON.stringify(singleMedia?.filename || null, null, 2)}</pre>
</div> </div>
<div class="value-item"> <div class="value-item">
<h4>Multiple Media ({multipleMedia.length}):</h4> <h4>Multiple Media ({multipleMedia.length}):</h4>
<pre>{JSON.stringify(multipleMedia.map(m => m.filename), null, 2)}</pre> <pre>{JSON.stringify(
multipleMedia.map((m) => m.filename),
null,
2
)}</pre>
</div> </div>
<div class="value-item"> <div class="value-item">
<h4>Featured Image:</h4> <h4>Featured Image:</h4>
<pre>{JSON.stringify(featuredImage?.filename || null, null, 2)}</pre> <pre>{JSON.stringify(featuredImage?.filename || null, null, 2)}</pre>
</div> </div>
<div class="value-item"> <div class="value-item">
<h4>Gallery Images ({galleryImages.length}):</h4> <h4>Gallery Images ({galleryImages.length}):</h4>
<pre>{JSON.stringify(galleryImages.map(m => m.filename), null, 2)}</pre> <pre>{JSON.stringify(
galleryImages.map((m) => m.filename),
null,
2
)}</pre>
</div> </div>
<div class="value-item"> <div class="value-item">
<h4>Project Gallery ({projectGallery.length}):</h4> <h4>Project Gallery ({projectGallery.length}):</h4>
<pre>{JSON.stringify(projectGallery.map(m => m.filename), null, 2)}</pre> <pre>{JSON.stringify(
projectGallery.map((m) => m.filename),
null,
2
)}</pre>
</div> </div>
</div> </div>
</section> </section>
@ -286,4 +290,4 @@
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
</style> </style>

View file

@ -39,12 +39,11 @@
<AdminPage title="ImageUploader Test" subtitle="Test the new direct upload functionality"> <AdminPage title="ImageUploader Test" subtitle="Test the new direct upload functionality">
<div class="test-container"> <div class="test-container">
<!-- Basic Image Upload --> <!-- Basic Image Upload -->
<section class="test-section"> <section class="test-section">
<h2>Basic Image Upload</h2> <h2>Basic Image Upload</h2>
<p>Standard image upload with alt text support.</p> <p>Standard image upload with alt text support.</p>
<ImageUploader <ImageUploader
label="Featured Image" label="Featured Image"
bind:value={singleImage} bind:value={singleImage}
@ -58,7 +57,7 @@
<section class="test-section"> <section class="test-section">
<h2>Square Logo Upload</h2> <h2>Square Logo Upload</h2>
<p>Image upload with 1:1 aspect ratio constraint.</p> <p>Image upload with 1:1 aspect ratio constraint.</p>
<ImageUploader <ImageUploader
label="Company Logo" label="Company Logo"
bind:value={logoImage} bind:value={logoImage}
@ -75,7 +74,7 @@
<section class="test-section"> <section class="test-section">
<h2>Banner Image Upload</h2> <h2>Banner Image Upload</h2>
<p>Wide banner image with 16:9 aspect ratio.</p> <p>Wide banner image with 16:9 aspect ratio.</p>
<ImageUploader <ImageUploader
label="Hero Banner" label="Hero Banner"
bind:value={bannerImage} bind:value={bannerImage}
@ -95,9 +94,7 @@
<button type="button" class="btn btn-primary" onclick={logAllValues}> <button type="button" class="btn btn-primary" onclick={logAllValues}>
Log All Values Log All Values
</button> </button>
<button type="button" class="btn btn-ghost" onclick={clearAll}> <button type="button" class="btn btn-ghost" onclick={clearAll}> Clear All </button>
Clear All
</button>
</div> </div>
</section> </section>
@ -107,36 +104,53 @@
<div class="values-display"> <div class="values-display">
<div class="value-item"> <div class="value-item">
<h4>Single Image:</h4> <h4>Single Image:</h4>
<pre>{JSON.stringify(singleImage ? { <pre>{JSON.stringify(
id: singleImage.id, singleImage
filename: singleImage.filename, ? {
altText: singleImage.altText, id: singleImage.id,
description: singleImage.description filename: singleImage.filename,
} : null, null, 2)}</pre> altText: singleImage.altText,
description: singleImage.description
}
: null,
null,
2
)}</pre>
</div> </div>
<div class="value-item"> <div class="value-item">
<h4>Logo Image:</h4> <h4>Logo Image:</h4>
<pre>{JSON.stringify(logoImage ? { <pre>{JSON.stringify(
id: logoImage.id, logoImage
filename: logoImage.filename, ? {
altText: logoImage.altText, id: logoImage.id,
description: logoImage.description filename: logoImage.filename,
} : null, null, 2)}</pre> altText: logoImage.altText,
description: logoImage.description
}
: null,
null,
2
)}</pre>
</div> </div>
<div class="value-item"> <div class="value-item">
<h4>Banner Image:</h4> <h4>Banner Image:</h4>
<pre>{JSON.stringify(bannerImage ? { <pre>{JSON.stringify(
id: bannerImage.id, bannerImage
filename: bannerImage.filename, ? {
altText: bannerImage.altText, id: bannerImage.id,
description: bannerImage.description filename: bannerImage.filename,
} : null, null, 2)}</pre> altText: bannerImage.altText,
description: bannerImage.description
}
: null,
null,
2
)}</pre>
</div> </div>
</div> </div>
</section> </section>
</div> </div>
</AdminPage> </AdminPage>
@ -255,4 +269,4 @@
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
</style> </style>

View file

@ -28,7 +28,7 @@
bind:value={textValue} bind:value={textValue}
helpText="This is a helpful hint" helpText="This is a helpful hint"
/> />
<Input <Input
type="email" type="email"
label="Email Input" label="Email Input"
@ -36,7 +36,7 @@
bind:value={emailValue} bind:value={emailValue}
required required
/> />
<Input <Input
type="password" type="password"
label="Password Input" label="Password Input"
@ -56,7 +56,7 @@
placeholder="https://example.com" placeholder="https://example.com"
bind:value={urlValue} bind:value={urlValue}
/> />
<Input <Input
type="search" type="search"
label="Search Input" label="Search Input"
@ -65,11 +65,11 @@
prefixIcon prefixIcon
> >
<svg slot="prefix" width="16" height="16" viewBox="0 0 16 16" fill="none"> <svg slot="prefix" width="16" height="16" viewBox="0 0 16 16" fill="none">
<circle cx="7" cy="7" r="5" stroke="currentColor" stroke-width="1.5"/> <circle cx="7" cy="7" r="5" stroke="currentColor" stroke-width="1.5" />
<path d="M11 11l3 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/> <path d="M11 11l3 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg> </svg>
</Input> </Input>
<Input <Input
type="number" type="number"
label="Number Input" label="Number Input"
@ -78,12 +78,8 @@
max={100} max={100}
step={5} step={5}
/> />
<Input <Input type="color" label="Color Input" bind:value={colorValue} />
type="color"
label="Color Input"
bind:value={colorValue}
/>
</div> </div>
</section> </section>
@ -102,23 +98,11 @@
<section> <section>
<h2>Input Sizes</h2> <h2>Input Sizes</h2>
<div class="input-group"> <div class="input-group">
<Input <Input buttonSize="small" label="Small Input" placeholder="Small size" />
buttonSize="small"
label="Small Input" <Input buttonSize="medium" label="Medium Input" placeholder="Medium size (default)" />
placeholder="Small size"
/> <Input buttonSize="large" label="Large Input" placeholder="Large size" />
<Input
buttonSize="medium"
label="Medium Input"
placeholder="Medium size (default)"
/>
<Input
buttonSize="large"
label="Large Input"
placeholder="Large size"
/>
</div> </div>
</section> </section>
@ -129,46 +113,49 @@
label="Input with Error" label="Input with Error"
placeholder="Try typing something" placeholder="Try typing something"
bind:value={withErrorValue} bind:value={withErrorValue}
error={withErrorValue.length > 0 && withErrorValue.length < 3 ? "Too short! Minimum 3 characters" : ""} error={withErrorValue.length > 0 && withErrorValue.length < 3
/> ? 'Too short! Minimum 3 characters'
: ''}
<Input
label="Disabled Input"
bind:value={disabledValue}
disabled
/>
<Input
label="Readonly Input"
bind:value={readonlyValue}
readonly
/> />
<Input label="Disabled Input" bind:value={disabledValue} disabled />
<Input label="Readonly Input" bind:value={readonlyValue} readonly />
</div> </div>
</section> </section>
<section> <section>
<h2>Input with Icons</h2> <h2>Input with Icons</h2>
<div class="input-group"> <div class="input-group">
<Input <Input label="With Prefix Icon" placeholder="Username" prefixIcon>
label="With Prefix Icon"
placeholder="Username"
prefixIcon
>
<svg slot="prefix" width="16" height="16" viewBox="0 0 16 16" fill="none"> <svg slot="prefix" width="16" height="16" viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="3" stroke="currentColor" stroke-width="1.5"/> <circle cx="8" cy="8" r="3" stroke="currentColor" stroke-width="1.5" />
<path d="M4 14c0-2.21 1.79-4 4-4s4 1.79 4 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/> <path
d="M4 14c0-2.21 1.79-4 4-4s4 1.79 4 4"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
/>
</svg> </svg>
</Input> </Input>
<Input <Input label="With Suffix Icon" placeholder="Email" type="email" suffixIcon>
label="With Suffix Icon"
placeholder="Email"
type="email"
suffixIcon
>
<svg slot="suffix" width="16" height="16" viewBox="0 0 16 16" fill="none"> <svg slot="suffix" width="16" height="16" viewBox="0 0 16 16" fill="none">
<rect x="2" y="4" width="12" height="8" rx="1" stroke="currentColor" stroke-width="1.5"/> <rect
<path d="M2 5l6 3 6-3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/> x="2"
y="4"
width="12"
height="8"
rx="1"
stroke="currentColor"
stroke-width="1.5"
/>
<path
d="M2 5l6 3 6-3"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
/>
</svg> </svg>
</Input> </Input>
</div> </div>
@ -184,7 +171,7 @@
showCharCount showCharCount
helpText="Keep it brief" helpText="Keep it brief"
/> />
<Input <Input
type="textarea" type="textarea"
label="Tweet-style Input" label="Tweet-style Input"
@ -198,19 +185,15 @@
<section> <section>
<h2>Form Example</h2> <h2>Form Example</h2>
<form class="demo-form" on:submit|preventDefault> <form class="demo-form" on:submit|preventDefault>
<Input <Input label="Project Name" placeholder="My Awesome Project" required />
label="Project Name"
placeholder="My Awesome Project"
required
/>
<Input <Input
type="url" type="url"
label="Project URL" label="Project URL"
placeholder="https://example.com" placeholder="https://example.com"
helpText="Include the full URL with https://" helpText="Include the full URL with https://"
/> />
<Input <Input
type="textarea" type="textarea"
label="Project Description" label="Project Description"
@ -219,7 +202,7 @@
maxLength={500} maxLength={500}
showCharCount showCharCount
/> />
<div class="form-actions"> <div class="form-actions">
<Button variant="secondary">Cancel</Button> <Button variant="secondary">Cancel</Button>
<Button variant="primary" type="submit">Save Project</Button> <Button variant="primary" type="submit">Save Project</Button>
@ -271,4 +254,4 @@
justify-content: flex-end; justify-content: flex-end;
margin-top: $unit-2x; margin-top: $unit-2x;
} }
</style> </style>

View file

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

View file

@ -34,7 +34,7 @@
function handleDrop(event: DragEvent) { function handleDrop(event: DragEvent) {
event.preventDefault() event.preventDefault()
dragActive = false dragActive = false
const droppedFiles = Array.from(event.dataTransfer?.files || []) const droppedFiles = Array.from(event.dataTransfer?.files || [])
addFiles(droppedFiles) addFiles(droppedFiles)
} }
@ -47,10 +47,13 @@
function addFiles(newFiles: File[]) { function addFiles(newFiles: File[]) {
// Filter for image files // Filter for image files
const imageFiles = newFiles.filter(file => file.type.startsWith('image/')) const imageFiles = newFiles.filter((file) => file.type.startsWith('image/'))
if (imageFiles.length !== newFiles.length) { if (imageFiles.length !== newFiles.length) {
uploadErrors = [...uploadErrors, `${newFiles.length - imageFiles.length} non-image files were skipped`] uploadErrors = [
...uploadErrors,
`${newFiles.length - imageFiles.length} non-image files were skipped`
]
} }
files = [...files, ...imageFiles] files = [...files, ...imageFiles]
@ -98,7 +101,7 @@
const response = await fetch('/api/media/upload', { const response = await fetch('/api/media/upload', {
method: 'POST', method: 'POST',
headers: { headers: {
'Authorization': `Basic ${auth}` Authorization: `Basic ${auth}`
}, },
body: formData body: formData
}) })
@ -145,8 +148,8 @@
<div class="upload-container"> <div class="upload-container">
<!-- Drop Zone --> <!-- Drop Zone -->
<div <div
class="drop-zone" class="drop-zone"
class:active={dragActive} class:active={dragActive}
class:has-files={files.length > 0} class:has-files={files.length > 0}
ondragover={handleDragOver} ondragover={handleDragOver}
@ -156,12 +159,54 @@
<div class="drop-zone-content"> <div class="drop-zone-content">
{#if files.length === 0} {#if files.length === 0}
<div class="upload-icon"> <div class="upload-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg
<path d="M14 2H6C4.9 2 4 2.9 4 4V20C4 21.1 4.9 22 6 22H18C19.1 22 20 21.1 20 20V8L14 2Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> width="48"
<polyline points="14,2 14,8 20,8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> height="48"
<line x1="16" y1="13" x2="8" y2="13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> viewBox="0 0 24 24"
<line x1="16" y1="17" x2="8" y2="17" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> fill="none"
<polyline points="10,9 9,9 8,9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> xmlns="http://www.w3.org/2000/svg"
>
<path
d="M14 2H6C4.9 2 4 2.9 4 4V20C4 21.1 4.9 22 6 22H18C19.1 22 20 21.1 20 20V8L14 2Z"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<polyline
points="14,2 14,8 20,8"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<line
x1="16"
y1="13"
x2="8"
y2="13"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<line
x1="16"
y1="17"
x2="8"
y2="17"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<polyline
points="10,9 9,9 8,9"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg> </svg>
</div> </div>
<h3>Drop images here</h3> <h3>Drop images here</h3>
@ -174,7 +219,7 @@
</div> </div>
{/if} {/if}
</div> </div>
<input <input
bind:this={fileInput} bind:this={fileInput}
type="file" type="file"
@ -183,9 +228,9 @@
onchange={handleFileSelect} onchange={handleFileSelect}
class="hidden-input" class="hidden-input"
/> />
<button <button
type="button" type="button"
class="drop-zone-button" class="drop-zone-button"
onclick={() => fileInput.click()} onclick={() => fileInput.click()}
disabled={isUploading} disabled={isUploading}
@ -200,13 +245,18 @@
<div class="file-list-header"> <div class="file-list-header">
<h3>Files to Upload</h3> <h3>Files to Upload</h3>
<div class="file-actions"> <div class="file-actions">
<Button variant="secondary" buttonSize="small" onclick={clearAll} disabled={isUploading}> <Button
variant="secondary"
buttonSize="small"
onclick={clearAll}
disabled={isUploading}
>
Clear All Clear All
</Button> </Button>
<Button <Button
variant="primary" variant="primary"
buttonSize="small" buttonSize="small"
onclick={uploadFiles} onclick={uploadFiles}
disabled={isUploading || files.length === 0} disabled={isUploading || files.length === 0}
> >
{#if isUploading} {#if isUploading}
@ -229,26 +279,33 @@
<div class="file-icon">📄</div> <div class="file-icon">📄</div>
{/if} {/if}
</div> </div>
<div class="file-info"> <div class="file-info">
<div class="file-name">{file.name}</div> <div class="file-name">{file.name}</div>
<div class="file-size">{formatFileSize(file.size)}</div> <div class="file-size">{formatFileSize(file.size)}</div>
{#if uploadProgress[file.name]} {#if uploadProgress[file.name]}
<div class="progress-bar"> <div class="progress-bar">
<div class="progress-fill" style="width: {uploadProgress[file.name]}%"></div> <div class="progress-fill" style="width: {uploadProgress[file.name]}%"></div>
</div> </div>
{/if} {/if}
</div> </div>
{#if !isUploading} {#if !isUploading}
<button <button
type="button" type="button"
class="remove-button" class="remove-button"
onclick={() => removeFile(index)} onclick={() => removeFile(index)}
title="Remove file" title="Remove file"
> >
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="18" y1="6" x2="6" y2="18"></line> <line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line> <line x1="6" y1="6" x2="18" y2="18"></line>
</svg> </svg>
@ -267,7 +324,7 @@
<div class="success-message"> <div class="success-message">
✅ Successfully uploaded {successCount} file{successCount !== 1 ? 's' : ''} ✅ Successfully uploaded {successCount} file{successCount !== 1 ? 's' : ''}
{#if successCount === files.length && uploadErrors.length === 0} {#if successCount === files.length && uploadErrors.length === 0}
<br><small>Redirecting to media library...</small> <br /><small>Redirecting to media library...</small>
{/if} {/if}
</div> </div>
{/if} {/if}
@ -324,7 +381,7 @@
.drop-zone-content { .drop-zone-content {
pointer-events: none; pointer-events: none;
.upload-icon { .upload-icon {
color: $grey-50; color: $grey-50;
margin-bottom: $unit-2x; margin-bottom: $unit-2x;
@ -492,7 +549,7 @@
.success-message { .success-message {
color: #16a34a; color: #16a34a;
margin-bottom: $unit-2x; margin-bottom: $unit-2x;
small { small {
color: $grey-50; color: $grey-50;
} }
@ -511,4 +568,4 @@
} }
} }
} }
</style> </style>

View file

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

View file

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

View file

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

View file

@ -189,12 +189,12 @@
font-weight: 700; font-weight: 700;
margin: 0; margin: 0;
color: $grey-10; color: $grey-10;
} }
.back-link { .back-link {
color: $grey-40; color: $grey-40;
text-decoration: none; text-decoration: none;
&:hover { &:hover {
color: $grey-20; color: $grey-20;
} }
@ -218,12 +218,12 @@
h2 { h2 {
margin: 0 0 $unit-2x; margin: 0 0 $unit-2x;
} }
p { p {
color: $grey-30; color: $grey-30;
margin-bottom: $unit-3x; margin-bottom: $unit-3x;
} }
} }
.status { .status {
@ -246,7 +246,7 @@
h3 { h3 {
font-size: 1rem; font-size: 1rem;
margin-bottom: $unit-2x; margin-bottom: $unit-2x;
} }
ol { ol {
margin: 0; margin: 0;
@ -255,7 +255,7 @@
li { li {
margin-bottom: $unit; margin-bottom: $unit;
color: $grey-30; color: $grey-30;
} }
} }
} }
@ -269,7 +269,7 @@
h3 { h3 {
margin: 0 0 $unit-3x; margin: 0 0 $unit-3x;
} }
} }
.results-section { .results-section {
@ -282,7 +282,7 @@
h3 { h3 {
margin: 0 0 $unit-3x; margin: 0 0 $unit-3x;
} }
} }
.image-grid { .image-grid {
@ -311,7 +311,7 @@
font-size: 0.875rem; font-size: 0.875rem;
color: $grey-40; color: $grey-40;
margin-bottom: $unit; margin-bottom: $unit;
} }
.url { .url {
display: block; display: block;
@ -328,7 +328,7 @@
padding: 2px 8px; padding: 2px 8px;
font-size: 0.75rem; font-size: 0.75rem;
border-radius: 4px; border-radius: 4px;
&.local { &.local {
background: #e6f0ff; background: #e6f0ff;
color: #0066cc; color: #0066cc;
@ -351,7 +351,7 @@
h3 { h3 {
margin: 0 0 $unit-3x; margin: 0 0 $unit-3x;
} }
pre { pre {
background: $grey-95; background: $grey-95;

View file

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

View file

@ -1,6 +1,11 @@
import type { RequestHandler } from './$types' import type { RequestHandler } from './$types'
import { prisma } from '$lib/server/database' import { prisma } from '$lib/server/database'
import { jsonResponse, errorResponse, checkAdminAuth, parseRequestBody } from '$lib/server/api-utils' import {
jsonResponse,
errorResponse,
checkAdminAuth,
parseRequestBody
} from '$lib/server/api-utils'
import { logger } from '$lib/server/logger' import { logger } from '$lib/server/logger'
// POST /api/albums/[id]/photos - Add a photo to an album // POST /api/albums/[id]/photos - Add a photo to an album
@ -84,10 +89,10 @@ export const POST: RequestHandler = async (event) => {
} }
}) })
logger.info('Photo added to album', { logger.info('Photo added to album', {
albumId, albumId,
photoId: photo.id, photoId: photo.id,
mediaId: body.mediaId mediaId: body.mediaId
}) })
// Return photo with media information for frontend compatibility // Return photo with media information for frontend compatibility
@ -141,7 +146,7 @@ export const PUT: RequestHandler = async (event) => {
// Update photo display order // Update photo display order
const photo = await prisma.photo.update({ const photo = await prisma.photo.update({
where: { where: {
id: body.photoId, id: body.photoId,
albumId // Ensure photo belongs to this album albumId // Ensure photo belongs to this album
}, },
@ -150,10 +155,10 @@ export const PUT: RequestHandler = async (event) => {
} }
}) })
logger.info('Photo order updated', { logger.info('Photo order updated', {
albumId, albumId,
photoId: body.photoId, photoId: body.photoId,
displayOrder: body.displayOrder displayOrder: body.displayOrder
}) })
return jsonResponse(photo) return jsonResponse(photo)
@ -178,9 +183,9 @@ export const DELETE: RequestHandler = async (event) => {
try { try {
const url = new URL(event.request.url) const url = new URL(event.request.url)
const photoId = url.searchParams.get('photoId') const photoId = url.searchParams.get('photoId')
logger.info('DELETE photo request', { albumId, photoId }) logger.info('DELETE photo request', { albumId, photoId })
if (!photoId || isNaN(parseInt(photoId))) { if (!photoId || isNaN(parseInt(photoId))) {
return errorResponse('Photo ID is required as query parameter', 400) return errorResponse('Photo ID is required as query parameter', 400)
} }
@ -199,7 +204,7 @@ export const DELETE: RequestHandler = async (event) => {
// Check if photo exists in this album // Check if photo exists in this album
const photo = await prisma.photo.findFirst({ const photo = await prisma.photo.findFirst({
where: { where: {
id: photoIdNum, id: photoIdNum,
albumId: albumId // Ensure photo belongs to this album albumId: albumId // Ensure photo belongs to this album
} }
@ -236,9 +241,9 @@ export const DELETE: RequestHandler = async (event) => {
where: { id: photoIdNum } where: { id: photoIdNum }
}) })
logger.info('Photo removed from album', { logger.info('Photo removed from album', {
photoId: photoIdNum, photoId: photoIdNum,
albumId: albumId albumId: albumId
}) })
return new Response(null, { status: 204 }) return new Response(null, { status: 204 })
@ -246,4 +251,4 @@ export const DELETE: RequestHandler = async (event) => {
logger.error('Failed to remove photo from album', error as Error) logger.error('Failed to remove photo from album', error as Error)
return errorResponse('Failed to remove photo from album', 500) return errorResponse('Failed to remove photo from album', 500)
} }
} }

View file

@ -16,9 +16,9 @@ export const GET: RequestHandler = async (event) => {
where: { slug }, where: { slug },
include: { include: {
photos: { photos: {
where: { where: {
status: 'published', status: 'published',
showInPhotos: true showInPhotos: true
}, },
orderBy: { displayOrder: 'asc' }, orderBy: { displayOrder: 'asc' },
select: { select: {
@ -33,11 +33,11 @@ export const GET: RequestHandler = async (event) => {
} }
}, },
_count: { _count: {
select: { select: {
photos: { photos: {
where: { where: {
status: 'published', status: 'published',
showInPhotos: true showInPhotos: true
} }
} }
} }
@ -54,4 +54,4 @@ export const GET: RequestHandler = async (event) => {
logger.error('Failed to retrieve album by slug', error as Error) logger.error('Failed to retrieve album by slug', error as Error)
return errorResponse('Failed to retrieve album', 500) return errorResponse('Failed to retrieve album', 500)
} }
} }

View file

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

View file

@ -13,7 +13,7 @@ export const PATCH: RequestHandler = async (event) => {
try { try {
const { id } = event.params const { id } = event.params
const mediaId = parseInt(id) const mediaId = parseInt(id)
if (isNaN(mediaId)) { if (isNaN(mediaId)) {
return errorResponse('Invalid media ID', 400) return errorResponse('Invalid media ID', 400)
} }
@ -57,7 +57,6 @@ export const PATCH: RequestHandler = async (event) => {
description: updatedMedia.description, description: updatedMedia.description,
updatedAt: updatedMedia.updatedAt updatedAt: updatedMedia.updatedAt
}) })
} catch (error) { } catch (error) {
logger.error('Media metadata update error', error as Error) logger.error('Media metadata update error', error as Error)
return errorResponse('Failed to update media metadata', 500) return errorResponse('Failed to update media metadata', 500)
@ -74,4 +73,4 @@ export const OPTIONS: RequestHandler = async () => {
'Access-Control-Allow-Headers': 'Content-Type, Authorization' 'Access-Control-Allow-Headers': 'Content-Type, Authorization'
} }
}) })
} }

View file

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

View file

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

View file

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

View file

@ -13,13 +13,13 @@ export const GET: RequestHandler = async (event) => {
// Fetch published photography albums // Fetch published photography albums
const albums = await prisma.album.findMany({ const albums = await prisma.album.findMany({
where: { where: {
status: 'published', status: 'published',
isPhotography: true isPhotography: true
}, },
include: { include: {
photos: { photos: {
where: { where: {
status: 'published' status: 'published'
}, },
orderBy: { displayOrder: 'asc' }, orderBy: { displayOrder: 'asc' },
@ -66,8 +66,8 @@ export const GET: RequestHandler = async (event) => {
// Transform albums to PhotoAlbum format // Transform albums to PhotoAlbum format
const photoAlbums: PhotoAlbum[] = albums const photoAlbums: PhotoAlbum[] = albums
.filter(album => album.photos.length > 0) // Only include albums with published photos .filter((album) => album.photos.length > 0) // Only include albums with published photos
.map(album => ({ .map((album) => ({
id: `album-${album.id}`, id: `album-${album.id}`,
slug: album.slug, // Add slug for navigation slug: album.slug, // Add slug for navigation
title: album.title, title: album.title,
@ -80,7 +80,7 @@ export const GET: RequestHandler = async (event) => {
width: album.photos[0].width || 400, width: album.photos[0].width || 400,
height: album.photos[0].height || 400 height: album.photos[0].height || 400
}, },
photos: album.photos.map(photo => ({ photos: album.photos.map((photo) => ({
id: `photo-${photo.id}`, id: `photo-${photo.id}`,
src: photo.url, src: photo.url,
alt: photo.caption || photo.filename, alt: photo.caption || photo.filename,
@ -92,7 +92,7 @@ export const GET: RequestHandler = async (event) => {
})) }))
// Transform individual photos to Photo format // Transform individual photos to Photo format
const photos: Photo[] = individualPhotos.map(photo => ({ const photos: Photo[] = individualPhotos.map((photo) => ({
id: `photo-${photo.id}`, id: `photo-${photo.id}`,
src: photo.url, src: photo.url,
alt: photo.title || photo.caption || photo.filename, alt: photo.title || photo.caption || photo.filename,
@ -126,4 +126,4 @@ export const GET: RequestHandler = async (event) => {
logger.error('Failed to fetch photos', error as Error) logger.error('Failed to fetch photos', error as Error)
return errorResponse('Failed to fetch photos', 500) return errorResponse('Failed to fetch photos', 500)
} }
} }

View file

@ -15,7 +15,7 @@ export const GET: RequestHandler = async (event) => {
try { try {
// First find the album // First find the album
const album = await prisma.album.findUnique({ const album = await prisma.album.findUnique({
where: { where: {
slug: albumSlug, slug: albumSlug,
status: 'published', status: 'published',
isPhotography: true isPhotography: true
@ -45,13 +45,13 @@ export const GET: RequestHandler = async (event) => {
} }
// Find the specific photo // Find the specific photo
const photo = album.photos.find(p => p.id === photoId) const photo = album.photos.find((p) => p.id === photoId)
if (!photo) { if (!photo) {
return errorResponse('Photo not found in album', 404) return errorResponse('Photo not found in album', 404)
} }
// Get photo index for navigation // Get photo index for navigation
const photoIndex = album.photos.findIndex(p => p.id === photoId) const photoIndex = album.photos.findIndex((p) => p.id === photoId)
const prevPhoto = photoIndex > 0 ? album.photos[photoIndex - 1] : null const prevPhoto = photoIndex > 0 ? album.photos[photoIndex - 1] : null
const nextPhoto = photoIndex < album.photos.length - 1 ? album.photos[photoIndex + 1] : null const nextPhoto = photoIndex < album.photos.length - 1 ? album.photos[photoIndex + 1] : null
@ -77,4 +77,4 @@ export const GET: RequestHandler = async (event) => {
logger.error('Failed to retrieve photo', error as Error) logger.error('Failed to retrieve photo', error as Error)
return errorResponse('Failed to retrieve photo', 500) return errorResponse('Failed to retrieve photo', 500)
} }
} }

View file

@ -70,9 +70,9 @@ export const DELETE: RequestHandler = async (event) => {
where: { id } where: { id }
}) })
logger.info('Photo deleted from album', { logger.info('Photo deleted from album', {
photoId: id, photoId: id,
albumId: photo.albumId albumId: photo.albumId
}) })
return new Response(null, { status: 204 }) return new Response(null, { status: 204 })
@ -126,4 +126,4 @@ export const PUT: RequestHandler = async (event) => {
logger.error('Failed to update photo', error as Error) logger.error('Failed to update photo', error as Error)
return errorResponse('Failed to update photo', 500) return errorResponse('Failed to update photo', 500)
} }
} }

View file

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

View file

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

View file

@ -50,4 +50,4 @@ export const GET: RequestHandler = async (event) => {
logger.error('Failed to retrieve post by slug', error as Error) logger.error('Failed to retrieve post by slug', error as Error)
return errorResponse('Failed to retrieve post', 500) return errorResponse('Failed to retrieve post', 500)
} }
} }

View file

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

View file

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

View file

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

View file

@ -12,13 +12,13 @@ export interface UniverseItem {
content?: any content?: any
publishedAt: string publishedAt: string
createdAt: string createdAt: string
// Post-specific fields // Post-specific fields
postType?: string postType?: string
linkUrl?: string linkUrl?: string
linkDescription?: string linkDescription?: string
attachments?: any attachments?: any
// Album-specific fields // Album-specific fields
description?: string description?: string
location?: string location?: string
@ -36,7 +36,7 @@ export const GET: RequestHandler = async (event) => {
// Fetch published posts // Fetch published posts
const posts = await prisma.post.findMany({ const posts = await prisma.post.findMany({
where: { where: {
status: 'published', status: 'published',
publishedAt: { not: null } publishedAt: { not: null }
}, },
@ -58,7 +58,7 @@ export const GET: RequestHandler = async (event) => {
// Fetch published albums marked for Universe // Fetch published albums marked for Universe
const albums = await prisma.album.findMany({ const albums = await prisma.album.findMany({
where: { where: {
status: 'published', status: 'published',
showInUniverse: true showInUniverse: true
}, },
@ -88,7 +88,7 @@ export const GET: RequestHandler = async (event) => {
}) })
// Transform posts to universe items // Transform posts to universe items
const postItems: UniverseItem[] = posts.map(post => ({ const postItems: UniverseItem[] = posts.map((post) => ({
id: post.id, id: post.id,
type: 'post' as const, type: 'post' as const,
slug: post.slug, slug: post.slug,
@ -104,7 +104,7 @@ export const GET: RequestHandler = async (event) => {
})) }))
// Transform albums to universe items // Transform albums to universe items
const albumItems: UniverseItem[] = albums.map(album => ({ const albumItems: UniverseItem[] = albums.map((album) => ({
id: album.id, id: album.id,
type: 'album' as const, type: 'album' as const,
slug: album.slug, slug: album.slug,
@ -120,8 +120,9 @@ export const GET: RequestHandler = async (event) => {
})) }))
// Combine and sort by publishedAt // Combine and sort by publishedAt
const allItems = [...postItems, ...albumItems] const allItems = [...postItems, ...albumItems].sort(
.sort((a, b) => new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime()) (a, b) => new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime()
)
// Apply pagination // Apply pagination
const paginatedItems = allItems.slice(offset, offset + limit) const paginatedItems = allItems.slice(offset, offset + limit)
@ -142,4 +143,4 @@ export const GET: RequestHandler = async (event) => {
logger.error('Failed to fetch universe feed', error as Error) logger.error('Failed to fetch universe feed', error as Error)
return errorResponse('Failed to fetch universe feed', 500) return errorResponse('Failed to fetch universe feed', 500)
} }
} }

View file

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

View file

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

View file

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

View file

@ -6,7 +6,7 @@ export const load: PageLoad = async ({ fetch }) => {
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to fetch photos') throw new Error('Failed to fetch photos')
} }
const data = await response.json() const data = await response.json()
return { return {
photoItems: data.photoItems || [], photoItems: data.photoItems || [],
@ -14,7 +14,7 @@ export const load: PageLoad = async ({ fetch }) => {
} }
} catch (error) { } catch (error) {
console.error('Error loading photos:', error) console.error('Error loading photos:', error)
// Fallback to empty array if API fails // Fallback to empty array if API fails
return { return {
photoItems: [], photoItems: [],
@ -22,4 +22,4 @@ export const load: PageLoad = async ({ fetch }) => {
error: 'Failed to load photos' error: 'Failed to load photos'
} }
} }
} }

Some files were not shown because too many files have changed in this diff Show more