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 { mergeConfig } from 'vite';
import path from 'path';
import type { StorybookConfig } from '@storybook/sveltekit'
import { mergeConfig } from 'vite'
import path from 'path'
const config: StorybookConfig = {
stories: [
"../src/**/*.mdx",
"../src/**/*.stories.@(js|ts|svelte)"
],
addons: [
"@storybook/addon-svelte-csf",
"@storybook/addon-docs",
"@storybook/addon-a11y"
],
framework: {
name: "@storybook/sveltekit",
options: {}
},
viteFinal: async (config) => {
return mergeConfig(config, {
resolve: {
alias: {
'$lib': path.resolve('./src/lib'),
'$components': path.resolve('./src/lib/components'),
'$icons': path.resolve('./src/assets/icons'),
'$illos': path.resolve('./src/assets/illos'),
'$styles': path.resolve('./src/assets/styles')
}
},
css: {
preprocessorOptions: {
scss: {
additionalData: `
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|ts|svelte)'],
addons: ['@storybook/addon-svelte-csf', '@storybook/addon-docs', '@storybook/addon-a11y'],
framework: {
name: '@storybook/sveltekit',
options: {}
},
viteFinal: async (config) => {
return mergeConfig(config, {
resolve: {
alias: {
$lib: path.resolve('./src/lib'),
$components: path.resolve('./src/lib/components'),
$icons: path.resolve('./src/assets/icons'),
$illos: path.resolve('./src/assets/illos'),
$styles: path.resolve('./src/assets/styles')
}
},
css: {
preprocessorOptions: {
scss: {
additionalData: `
@import './src/assets/styles/variables.scss';
@import './src/assets/styles/fonts.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 '../src/assets/styles/reset.css';
import '../src/assets/styles/globals.scss';
import type { Preview } from '@storybook/sveltekit'
import '../src/assets/styles/reset.css'
import '../src/assets/styles/globals.scss'
const preview: Preview = {
parameters: {
actions: { argTypesRegex: '^on[A-Z].*' },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
backgrounds: {
default: 'light',
values: [
{ name: 'light', value: '#ffffff' },
{ name: 'dark', value: '#333333' },
{ name: 'admin', value: '#f5f5f5' },
{ name: 'grey-95', value: '#f8f9fa' },
],
},
viewport: {
viewports: {
mobile: {
name: 'Mobile',
styles: { width: '375px', height: '667px' }
},
tablet: {
name: 'Tablet',
styles: { width: '768px', height: '1024px' }
},
desktop: {
name: 'Desktop',
styles: { width: '1440px', height: '900px' }
},
},
},
},
};
parameters: {
actions: { argTypesRegex: '^on[A-Z].*' },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/
}
},
backgrounds: {
default: 'light',
values: [
{ name: 'light', value: '#ffffff' },
{ name: 'dark', value: '#333333' },
{ name: 'admin', value: '#f5f5f5' },
{ name: 'grey-95', value: '#f8f9fa' }
]
},
viewport: {
viewports: {
mobile: {
name: 'Mobile',
styles: { width: '375px', height: '667px' }
},
tablet: {
name: 'Tablet',
styles: { width: '768px', height: '1024px' }
},
desktop: {
name: 'Desktop',
styles: { width: '1440px', height: '900px' }
}
}
}
}
}
export default preview;
export default preview

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -13,15 +13,19 @@ declare module '@tiptap/core' {
/**
* Insert a gallery
*/
setGallery: (options: { images: Array<{ id: number; url: string; alt?: string; title?: string }> }) => ReturnType
setGallery: (options: {
images: Array<{ id: number; url: string; alt?: string; title?: string }>
}) => ReturnType
}
}
}
export const GalleryExtended = (component: Component<NodeViewProps>): Node<GalleryOptions, unknown> => {
export const GalleryExtended = (
component: Component<NodeViewProps>
): Node<GalleryOptions, unknown> => {
return Node.create<GalleryOptions>({
name: 'gallery',
addOptions() {
return {
HTMLAttributes: {}
@ -46,15 +50,16 @@ export const GalleryExtended = (component: Component<NodeViewProps>): Node<Galle
},
parseHTML() {
return [
{ tag: `div[data-type="${this.name}"]` }
]
return [{ tag: `div[data-type="${this.name}"]` }]
},
renderHTML({ HTMLAttributes }) {
return ['div', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
'data-type': this.name
})]
return [
'div',
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
'data-type': this.name
})
]
},
group: 'block',
@ -67,15 +72,17 @@ export const GalleryExtended = (component: Component<NodeViewProps>): Node<Galle
addCommands() {
return {
setGallery: (options) => ({ commands }) => {
return commands.insertContent({
type: this.name,
attrs: {
images: options.images
}
})
}
setGallery:
(options) =>
({ commands }) => {
return commands.insertContent({
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 { EditorState, Selection, Transaction } from '@tiptap/pm/state'
import { CellSelection, type Rect, TableMap } from '@tiptap/pm/tables'
import { Node, ResolvedPos } from '@tiptap/pm/model'
import type { EditorView } from '@tiptap/pm/view'
import Table from './table.js'

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -16,9 +16,9 @@ export const GET: RequestHandler = async (event) => {
where: { slug },
include: {
photos: {
where: {
where: {
status: 'published',
showInPhotos: true
showInPhotos: true
},
orderBy: { displayOrder: 'asc' },
select: {
@ -33,11 +33,11 @@ export const GET: RequestHandler = async (event) => {
}
},
_count: {
select: {
select: {
photos: {
where: {
where: {
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)
return errorResponse('Failed to retrieve album', 500)
}
}
}

View file

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

View file

@ -13,7 +13,7 @@ export const PATCH: RequestHandler = async (event) => {
try {
const { id } = event.params
const mediaId = parseInt(id)
if (isNaN(mediaId)) {
return errorResponse('Invalid media ID', 400)
}
@ -57,7 +57,6 @@ export const PATCH: RequestHandler = async (event) => {
description: updatedMedia.description,
updatedAt: updatedMedia.updatedAt
})
} catch (error) {
logger.error('Media metadata update error', error as Error)
return errorResponse('Failed to update media metadata', 500)
@ -74,4 +73,4 @@ export const OPTIONS: RequestHandler = async () => {
'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 { jsonResponse, errorResponse, checkAdminAuth } from '$lib/server/api-utils'
import { logger } from '$lib/server/logger'
import { trackMediaUsage, extractMediaIds, removeMediaUsage, type MediaUsageReference } from '$lib/server/media-usage.js'
import {
trackMediaUsage,
extractMediaIds,
removeMediaUsage,
type MediaUsageReference
} from '$lib/server/media-usage.js'
// POST /api/media/backfill-usage - Backfill media usage tracking for all content
export const POST: RequestHandler = async (event) => {
@ -32,7 +37,7 @@ export const POST: RequestHandler = async (event) => {
for (const project of projects) {
// Track featured image
const featuredImageIds = extractMediaIds(project, 'featuredImage')
featuredImageIds.forEach(mediaId => {
featuredImageIds.forEach((mediaId) => {
usageReferences.push({
mediaId,
contentType: 'project',
@ -43,7 +48,7 @@ export const POST: RequestHandler = async (event) => {
// Track logo
const logoIds = extractMediaIds(project, 'logoUrl')
logoIds.forEach(mediaId => {
logoIds.forEach((mediaId) => {
usageReferences.push({
mediaId,
contentType: 'project',
@ -54,7 +59,7 @@ export const POST: RequestHandler = async (event) => {
// Track gallery images
const galleryIds = extractMediaIds(project, 'gallery')
galleryIds.forEach(mediaId => {
galleryIds.forEach((mediaId) => {
usageReferences.push({
mediaId,
contentType: 'project',
@ -65,7 +70,7 @@ export const POST: RequestHandler = async (event) => {
// Track media in case study content
const contentIds = extractMediaIds(project, 'caseStudyContent')
contentIds.forEach(mediaId => {
contentIds.forEach((mediaId) => {
usageReferences.push({
mediaId,
contentType: 'project',
@ -88,7 +93,7 @@ export const POST: RequestHandler = async (event) => {
for (const post of posts) {
// Track featured image
const featuredImageIds = extractMediaIds(post, 'featuredImage')
featuredImageIds.forEach(mediaId => {
featuredImageIds.forEach((mediaId) => {
usageReferences.push({
mediaId,
contentType: 'post',
@ -99,7 +104,7 @@ export const POST: RequestHandler = async (event) => {
// Track attachments
const attachmentIds = extractMediaIds(post, 'attachments')
attachmentIds.forEach(mediaId => {
attachmentIds.forEach((mediaId) => {
usageReferences.push({
mediaId,
contentType: 'post',
@ -110,7 +115,7 @@ export const POST: RequestHandler = async (event) => {
// Track media in post content
const contentIds = extractMediaIds(post, 'content')
contentIds.forEach(mediaId => {
contentIds.forEach((mediaId) => {
usageReferences.push({
mediaId,
contentType: 'post',
@ -139,4 +144,4 @@ export const POST: RequestHandler = async (event) => {
logger.error('Failed to backfill media usage', error as Error)
return errorResponse('Failed to backfill media usage', 500)
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -70,9 +70,9 @@ export const DELETE: RequestHandler = async (event) => {
where: { id }
})
logger.info('Photo deleted from album', {
photoId: id,
albumId: photo.albumId
logger.info('Photo deleted from album', {
photoId: id,
albumId: photo.albumId
})
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)
return errorResponse('Failed to update photo', 500)
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -37,11 +37,18 @@
</Page>
{:else if project.status === 'password-protected'}
<Page>
<ProjectPasswordProtection projectSlug={project.slug} correctPassword={project.password || ''} projectType="labs">
<ProjectPasswordProtection
projectSlug={project.slug}
correctPassword={project.password || ''}
projectType="labs"
>
{#snippet children()}
<div slot="header" class="project-header">
{#if project.logoUrl}
<div class="project-logo" style="background-color: {project.backgroundColor || '#f5f5f5'}">
<div
class="project-logo"
style="background-color: {project.backgroundColor || '#f5f5f5'}"
>
<img src={project.logoUrl} alt="{project.title} logo" />
</div>
{/if}
@ -149,4 +156,4 @@
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 }) => {
try {
// Find project by slug - we'll fetch all published, list-only, and password-protected projects
const response = await fetch(`/api/projects?projectType=labs&includeListOnly=true&includePasswordProtected=true`)
const response = await fetch(
`/api/projects?projectType=labs&includeListOnly=true&includePasswordProtected=true`
)
if (!response.ok) {
throw new Error('Failed to fetch projects')
}
@ -31,4 +33,4 @@ export const load: PageLoad = async ({ params, fetch }) => {
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) {
throw new Error('Failed to fetch photos')
}
const data = await response.json()
return {
photoItems: data.photoItems || [],
@ -14,7 +14,7 @@ export const load: PageLoad = async ({ fetch }) => {
}
} catch (error) {
console.error('Error loading photos:', error)
// Fallback to empty array if API fails
return {
photoItems: [],
@ -22,4 +22,4 @@ export const load: PageLoad = async ({ fetch }) => {
error: 'Failed to load photos'
}
}
}
}

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