Prettier + build errors
This commit is contained in:
parent
2a6291a547
commit
78443e2bdd
127 changed files with 2873 additions and 2292 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -38,7 +38,6 @@ We are using Svelte 5 in Runes mode, so make sure to only write solutions that w
|
|||
|
||||
Make sure to use the CSS variables that are defined across the various files in `src/assets/styles`. When making new colors or defining new variables, check that it doesn't exist first, then define it.
|
||||
|
||||
|
||||
### Key Architecture Components
|
||||
|
||||
**API Integration Layer** (`src/routes/api/`)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
// For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format
|
||||
import storybook from "eslint-plugin-storybook";
|
||||
import storybook from 'eslint-plugin-storybook'
|
||||
|
||||
import js from '@eslint/js'
|
||||
import ts from 'typescript-eslint'
|
||||
|
|
@ -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']
|
||||
]
|
||||
|
|
|
|||
230
package.json
230
package.json
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.'
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,11 +11,11 @@
|
|||
display: inline-block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
|
||||
:global(svg) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: $avatar-radius;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -278,4 +278,4 @@
|
|||
color: $grey-20;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -39,4 +39,4 @@
|
|||
font-size: 1.125rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -40,4 +40,4 @@
|
|||
gap: $unit-half;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -36,4 +36,4 @@
|
|||
align-items: center;
|
||||
gap: $unit-2x;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -95,10 +95,10 @@
|
|||
})
|
||||
</script>
|
||||
|
||||
<GenericMetadataPopover
|
||||
<GenericMetadataPopover
|
||||
{config}
|
||||
bind:data={popoverData}
|
||||
{triggerElement}
|
||||
onUpdate={handleDateChange}
|
||||
{onClose}
|
||||
/>
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -25,4 +25,4 @@
|
|||
min-width: 180px;
|
||||
z-index: 10;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@
|
|||
const timer = setTimeout(() => {
|
||||
editor.commands.focus()
|
||||
}, 100)
|
||||
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -258,7 +258,12 @@
|
|||
<div class="file-list-header">
|
||||
<h3>Files to Upload</h3>
|
||||
<div class="file-actions">
|
||||
<Button variant="secondary" buttonSize="small" onclick={clearAll} disabled={isUploading}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
buttonSize="small"
|
||||
onclick={clearAll}
|
||||
disabled={isUploading}
|
||||
>
|
||||
Clear All
|
||||
</Button>
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -132,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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@
|
|||
font-weight: 600;
|
||||
margin: 0 0 $unit-3x;
|
||||
color: $grey-10;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.form-row {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -22,8 +22,7 @@
|
|||
|
||||
import { Extension, type Range, type Dispatch } from '@tiptap/core'
|
||||
import { Decoration, DecorationSet } from '@tiptap/pm/view'
|
||||
import { Plugin, PluginKey, type EditorState, type Transaction } from '@tiptap/pm/state'
|
||||
import { Node as PMNode } from '@tiptap/pm/model'
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
|
|
|
|||
|
|
@ -13,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
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,4 +54,4 @@ export const GalleryPlaceholder = (
|
|||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
import { Editor, findParentNode } from '@tiptap/core'
|
||||
import { EditorState, Selection, Transaction } from '@tiptap/pm/state'
|
||||
import { CellSelection, type Rect, TableMap } from '@tiptap/pm/tables'
|
||||
import { Node, ResolvedPos } from '@tiptap/pm/model'
|
||||
import type { EditorView } from '@tiptap/pm/view'
|
||||
import Table from './table.js'
|
||||
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@
|
|||
|
||||
function handleMediaSelect(media: Media | Media[]) {
|
||||
const mediaArray = Array.isArray(media) ? media : [media]
|
||||
const newImages = mediaArray.map(m => ({
|
||||
const newImages = mediaArray.map((m) => ({
|
||||
id: m.id,
|
||||
url: m.url,
|
||||
alt: m.altText || '',
|
||||
|
|
@ -40,7 +40,7 @@
|
|||
// Add to existing images
|
||||
const existingImages = node.attrs.images || []
|
||||
const currentIds = existingImages.map((img: any) => img.id)
|
||||
const uniqueNewImages = newImages.filter(img => !currentIds.includes(img.id))
|
||||
const uniqueNewImages = newImages.filter((img) => !currentIds.includes(img.id))
|
||||
updateAttributes({ images: [...existingImages, ...uniqueNewImages] })
|
||||
}
|
||||
|
||||
|
|
@ -87,12 +87,7 @@
|
|||
<div class={`edra-gallery-grid ${layout === 'masonry' ? 'masonry' : 'grid'}`}>
|
||||
{#each images as image}
|
||||
<div class="edra-gallery-item">
|
||||
<img
|
||||
src={image.url}
|
||||
alt={image.alt}
|
||||
title={image.title}
|
||||
loading="lazy"
|
||||
/>
|
||||
<img src={image.url} alt={image.alt} title={image.title} loading="lazy" />
|
||||
{#if editor?.isEditable}
|
||||
<button
|
||||
class="edra-gallery-item-remove"
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import type { Content, Editor } from '@tiptap/core'
|
||||
import { Node } from '@tiptap/pm/model'
|
||||
import { Decoration, DecorationSet } from '@tiptap/pm/view'
|
||||
import type { EditorState, Transaction } from '@tiptap/pm/state'
|
||||
import type { EditorView } from '@tiptap/pm/view'
|
||||
|
|
|
|||
|
|
@ -1,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>
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -5,4 +5,4 @@
|
|||
onMount(() => {
|
||||
goto('/admin/projects', { replaceState: true })
|
||||
})
|
||||
</script>
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -238,7 +238,8 @@
|
|||
{#if photographyFilter === 'all'}
|
||||
No albums found. Create your first album!
|
||||
{:else}
|
||||
No albums found matching the current filters. Try adjusting your filters or create a new album.
|
||||
No albums found matching the current filters. Try adjusting your filters or create a new
|
||||
album.
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -577,7 +577,7 @@
|
|||
triggerElement={metadataButtonElement}
|
||||
onUpdate={handleMetadataUpdate}
|
||||
onDelete={handleMetadataDelete}
|
||||
onClose={() => isMetadataOpen = false}
|
||||
onClose={() => (isMetadataOpen = false)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -258,7 +258,7 @@
|
|||
triggerElement={metadataButtonElement}
|
||||
onUpdate={handleMetadataUpdate}
|
||||
onDelete={() => {}}
|
||||
onClose={() => isMetadataOpen = false}
|
||||
onClose={() => (isMetadataOpen = false)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -30,22 +30,42 @@
|
|||
<div class="button-group">
|
||||
<Button buttonSize="small" iconOnly>
|
||||
<svg slot="icon" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M8 4v8m4-4H4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<path
|
||||
d="M8 4v8m4-4H4"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
<Button buttonSize="medium" iconOnly>
|
||||
<svg slot="icon" width="18" height="18" viewBox="0 0 18 18" fill="none">
|
||||
<path d="M9 5v8m4-4H5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<path
|
||||
d="M9 5v8m4-4H5"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
<Button buttonSize="large" iconOnly>
|
||||
<svg slot="icon" width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<path d="M10 6v8m4-4H6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<path
|
||||
d="M10 6v8m4-4H6"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
<Button buttonSize="icon" iconOnly variant="ghost">
|
||||
<svg slot="icon" width="18" height="18" viewBox="0 0 18 18" fill="none">
|
||||
<path d="M6 6l6 6m0-6l-6 6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<path
|
||||
d="M6 6l6 6m0-6l-6 6"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -56,14 +76,25 @@
|
|||
<div class="button-group">
|
||||
<Button>
|
||||
<svg slot="icon" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M8 4v8m4-4H4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<path
|
||||
d="M8 4v8m4-4H4"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
Add Item
|
||||
</Button>
|
||||
<Button iconPosition="right" variant="secondary">
|
||||
Next
|
||||
<svg slot="icon" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M6 4l4 4-4 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path
|
||||
d="M6 4l4 4-4 4"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -121,4 +152,4 @@
|
|||
gap: $unit-2x;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -171,7 +171,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
function handleMetadataPopover(event: MouseEvent) {
|
||||
const target = event.target as Node
|
||||
// Don't close if clicking inside the metadata button or anywhere in a metadata popover
|
||||
|
|
@ -184,7 +183,6 @@
|
|||
showMetadata = false
|
||||
}
|
||||
|
||||
|
||||
$effect(() => {
|
||||
if (showMetadata) {
|
||||
document.addEventListener('click', handleMetadataPopover)
|
||||
|
|
@ -240,7 +238,7 @@
|
|||
onAddTag={addTag}
|
||||
onRemoveTag={removeTag}
|
||||
onDelete={openDeleteConfirmation}
|
||||
onClose={() => showMetadata = false}
|
||||
onClose={() => (showMetadata = false)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -373,7 +371,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
.btn {
|
||||
padding: $unit-2x $unit-3x;
|
||||
border: none;
|
||||
|
|
|
|||
|
|
@ -114,8 +114,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Mock post object for metadata popover
|
||||
const mockPost = $derived({
|
||||
id: null,
|
||||
|
|
@ -171,7 +169,7 @@
|
|||
onAddTag={addTag}
|
||||
onRemoveTag={removeTag}
|
||||
onDelete={() => {}}
|
||||
onClose={() => showMetadata = false}
|
||||
onClose={() => (showMetadata = false)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -252,7 +250,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
.btn {
|
||||
padding: $unit-2x $unit-3x;
|
||||
border: none;
|
||||
|
|
|
|||
|
|
@ -233,7 +233,8 @@
|
|||
{#if selectedStatusFilter === 'all' && selectedTypeFilter === 'all'}
|
||||
No projects found. Create your first project!
|
||||
{:else}
|
||||
No projects found matching the current filters. Try adjusting your filters or create a new project.
|
||||
No projects found matching the current filters. Try adjusting your filters or create a
|
||||
new project.
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -263,8 +264,6 @@
|
|||
/>
|
||||
|
||||
<style lang="scss">
|
||||
|
||||
|
||||
.error {
|
||||
text-align: center;
|
||||
padding: $unit-6x;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,11 @@ import {
|
|||
checkAdminAuth
|
||||
} from '$lib/server/api-utils'
|
||||
import { logger } from '$lib/server/logger'
|
||||
import { trackMediaUsage, extractMediaIds, type MediaUsageReference } from '$lib/server/media-usage.js'
|
||||
import {
|
||||
trackMediaUsage,
|
||||
extractMediaIds,
|
||||
type MediaUsageReference
|
||||
} from '$lib/server/media-usage.js'
|
||||
|
||||
// GET /api/posts - List all posts
|
||||
export const GET: RequestHandler = async (event) => {
|
||||
|
|
@ -126,7 +130,8 @@ export const POST: RequestHandler = async (event) => {
|
|||
linkUrl: data.link_url,
|
||||
linkDescription: data.linkDescription,
|
||||
featuredImage: featuredImageId,
|
||||
attachments: data.attachedPhotos && data.attachedPhotos.length > 0 ? data.attachedPhotos : null,
|
||||
attachments:
|
||||
data.attachedPhotos && data.attachedPhotos.length > 0 ? data.attachedPhotos : null,
|
||||
tags: data.tags,
|
||||
publishedAt: data.publishedAt
|
||||
}
|
||||
|
|
@ -138,7 +143,7 @@ export const POST: RequestHandler = async (event) => {
|
|||
|
||||
// Track featured image
|
||||
const featuredImageIds = extractMediaIds({ featuredImage: featuredImageId }, 'featuredImage')
|
||||
featuredImageIds.forEach(mediaId => {
|
||||
featuredImageIds.forEach((mediaId) => {
|
||||
usageReferences.push({
|
||||
mediaId,
|
||||
contentType: 'post',
|
||||
|
|
@ -173,7 +178,7 @@ export const POST: RequestHandler = async (event) => {
|
|||
|
||||
// Track media in post content
|
||||
const contentIds = extractMediaIds({ content: postContent }, 'content')
|
||||
contentIds.forEach(mediaId => {
|
||||
contentIds.forEach((mediaId) => {
|
||||
usageReferences.push({
|
||||
mediaId,
|
||||
contentType: 'post',
|
||||
|
|
|
|||
|
|
@ -2,7 +2,13 @@ import type { RequestHandler } from './$types'
|
|||
import { prisma } from '$lib/server/database'
|
||||
import { jsonResponse, errorResponse, checkAdminAuth } from '$lib/server/api-utils'
|
||||
import { logger } from '$lib/server/logger'
|
||||
import { updateMediaUsage, removeMediaUsage, extractMediaIds, trackMediaUsage, type MediaUsageReference } from '$lib/server/media-usage.js'
|
||||
import {
|
||||
updateMediaUsage,
|
||||
removeMediaUsage,
|
||||
extractMediaIds,
|
||||
trackMediaUsage,
|
||||
type MediaUsageReference
|
||||
} from '$lib/server/media-usage.js'
|
||||
|
||||
// GET /api/posts/[id] - Get a single post
|
||||
export const GET: RequestHandler = async (event) => {
|
||||
|
|
@ -93,7 +99,8 @@ export const PUT: RequestHandler = async (event) => {
|
|||
linkUrl: data.link_url,
|
||||
linkDescription: data.linkDescription,
|
||||
featuredImage: featuredImageId,
|
||||
attachments: data.attachedPhotos && data.attachedPhotos.length > 0 ? data.attachedPhotos : null,
|
||||
attachments:
|
||||
data.attachedPhotos && data.attachedPhotos.length > 0 ? data.attachedPhotos : null,
|
||||
tags: data.tags,
|
||||
publishedAt: data.publishedAt
|
||||
}
|
||||
|
|
@ -109,7 +116,7 @@ export const PUT: RequestHandler = async (event) => {
|
|||
|
||||
// Track featured image
|
||||
const featuredImageIds = extractMediaIds(post, 'featuredImage')
|
||||
featuredImageIds.forEach(mediaId => {
|
||||
featuredImageIds.forEach((mediaId) => {
|
||||
usageReferences.push({
|
||||
mediaId,
|
||||
contentType: 'post',
|
||||
|
|
@ -120,7 +127,7 @@ export const PUT: RequestHandler = async (event) => {
|
|||
|
||||
// Track attachments
|
||||
const attachmentIds = extractMediaIds(post, 'attachments')
|
||||
attachmentIds.forEach(mediaId => {
|
||||
attachmentIds.forEach((mediaId) => {
|
||||
usageReferences.push({
|
||||
mediaId,
|
||||
contentType: 'post',
|
||||
|
|
@ -131,7 +138,7 @@ export const PUT: RequestHandler = async (event) => {
|
|||
|
||||
// Track media in post content
|
||||
const contentIds = extractMediaIds(post, 'content')
|
||||
contentIds.forEach(mediaId => {
|
||||
contentIds.forEach((mediaId) => {
|
||||
usageReferences.push({
|
||||
mediaId,
|
||||
contentType: 'post',
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -8,7 +8,12 @@ import {
|
|||
} from '$lib/server/api-utils'
|
||||
import { logger } from '$lib/server/logger'
|
||||
import { ensureUniqueSlug } from '$lib/server/database'
|
||||
import { updateMediaUsage, removeMediaUsage, extractMediaIds, type MediaUsageReference } from '$lib/server/media-usage.js'
|
||||
import {
|
||||
updateMediaUsage,
|
||||
removeMediaUsage,
|
||||
extractMediaIds,
|
||||
type MediaUsageReference
|
||||
} from '$lib/server/media-usage.js'
|
||||
|
||||
// GET /api/projects/[id] - Get a single project
|
||||
export const GET: RequestHandler = async (event) => {
|
||||
|
|
@ -103,7 +108,7 @@ export const PUT: RequestHandler = async (event) => {
|
|||
|
||||
// Track featured image
|
||||
const featuredImageIds = extractMediaIds(project, 'featuredImage')
|
||||
featuredImageIds.forEach(mediaId => {
|
||||
featuredImageIds.forEach((mediaId) => {
|
||||
usageReferences.push({
|
||||
mediaId,
|
||||
contentType: 'project',
|
||||
|
|
@ -114,7 +119,7 @@ export const PUT: RequestHandler = async (event) => {
|
|||
|
||||
// Track logo
|
||||
const logoIds = extractMediaIds(project, 'logoUrl')
|
||||
logoIds.forEach(mediaId => {
|
||||
logoIds.forEach((mediaId) => {
|
||||
usageReferences.push({
|
||||
mediaId,
|
||||
contentType: 'project',
|
||||
|
|
@ -125,7 +130,7 @@ export const PUT: RequestHandler = async (event) => {
|
|||
|
||||
// Track gallery images
|
||||
const galleryIds = extractMediaIds(project, 'gallery')
|
||||
galleryIds.forEach(mediaId => {
|
||||
galleryIds.forEach((mediaId) => {
|
||||
usageReferences.push({
|
||||
mediaId,
|
||||
contentType: 'project',
|
||||
|
|
@ -136,7 +141,7 @@ export const PUT: RequestHandler = async (event) => {
|
|||
|
||||
// Track media in case study content
|
||||
const contentIds = extractMediaIds(project, 'caseStudyContent')
|
||||
contentIds.forEach(mediaId => {
|
||||
contentIds.forEach((mediaId) => {
|
||||
usageReferences.push({
|
||||
mediaId,
|
||||
contentType: 'project',
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import 'dotenv/config'
|
||||
import { error, json } from '@sveltejs/kit'
|
||||
import redis from '../redis-client'
|
||||
import SteamAPI, { Game, GameInfo, GameInfoExtended, UserPlaytime } from 'steamapi'
|
||||
import SteamAPI from 'steamapi'
|
||||
|
||||
import type { RequestHandler } from './$types'
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 || []
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue