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.'
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ export function getAuthHeaders(): HeadersInit {
|
|||
}
|
||||
|
||||
return {
|
||||
'Authorization': `Basic ${adminCredentials}`
|
||||
Authorization: `Basic ${adminCredentials}`
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -32,7 +32,10 @@ export function clearAuth() {
|
|||
}
|
||||
|
||||
// Make authenticated API request
|
||||
export async function authenticatedFetch(url: string, options: RequestInit = {}): Promise<Response> {
|
||||
export async function authenticatedFetch(
|
||||
url: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<Response> {
|
||||
const headers = {
|
||||
...getAuthHeaders(),
|
||||
...options.headers
|
||||
|
|
|
|||
|
|
@ -14,9 +14,12 @@
|
|||
|
||||
const getPostTypeLabel = (postType: string) => {
|
||||
switch (postType) {
|
||||
case 'post': return 'Post'
|
||||
case 'essay': return 'Essay'
|
||||
default: return 'Post'
|
||||
case 'post':
|
||||
return 'Post'
|
||||
case 'essay':
|
||||
return 'Essay'
|
||||
default:
|
||||
return 'Post'
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -42,18 +45,22 @@
|
|||
|
||||
case 'bulletList':
|
||||
case 'ul':
|
||||
const listItems = (block.content || []).map((item: any) => {
|
||||
const 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,9 +38,18 @@
|
|||
{#if project.status === 'password-protected'}
|
||||
<div class="status-indicator password-protected">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" stroke="currentColor" stroke-width="2"/>
|
||||
<circle cx="12" cy="16" r="1" fill="currentColor"/>
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" stroke="currentColor" stroke-width="2"/>
|
||||
<rect
|
||||
x="3"
|
||||
y="11"
|
||||
width="18"
|
||||
height="11"
|
||||
rx="2"
|
||||
ry="2"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<circle cx="12" cy="16" r="1" fill="currentColor" />
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" stroke="currentColor" stroke-width="2" />
|
||||
</svg>
|
||||
<span>Password Protected</span>
|
||||
</div>
|
||||
|
|
@ -81,8 +90,20 @@
|
|||
{#if project.status === 'list-only'}
|
||||
<div class="status-indicator list-only">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M1 1l22 22" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path
|
||||
d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M1 1l22 22"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
<span>View Only</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@
|
|||
error = ''
|
||||
|
||||
// Simulate a small delay for better UX
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
|
||||
if (password === correctPassword) {
|
||||
// Store in session storage
|
||||
|
|
@ -63,7 +63,13 @@
|
|||
{#snippet passwordHeader()}
|
||||
<div class="password-header">
|
||||
<div class="lock-icon">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg
|
||||
width="48"
|
||||
height="48"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M18 11H6C5.45 11 5 11.45 5 12V19C5 19.55 5.45 20 6 20H18C18.55 20 19 19.55 19 19V12C19 11.45 18.55 11 18 11Z"
|
||||
stroke="currentColor"
|
||||
|
|
@ -191,7 +197,9 @@
|
|||
border: 1px solid $grey-80;
|
||||
border-radius: $unit;
|
||||
font-size: 1rem;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
|
|
|
|||
|
|
@ -17,9 +17,7 @@
|
|||
<article class="universe-album-card">
|
||||
<div class="card-content">
|
||||
<div class="card-header">
|
||||
<div class="album-type-badge">
|
||||
Album
|
||||
</div>
|
||||
<div class="album-type-badge">Album</div>
|
||||
<time class="album-date" datetime={album.publishedAt}>
|
||||
{formatDate(album.publishedAt)}
|
||||
</time>
|
||||
|
|
@ -60,9 +58,7 @@
|
|||
</div>
|
||||
|
||||
<div class="card-footer">
|
||||
<a href="/photos/{album.slug}" class="view-album">
|
||||
View album →
|
||||
</a>
|
||||
<a href="/photos/{album.slug}" class="view-album"> View album → </a>
|
||||
<UniverseIcon class="universe-icon" />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -15,9 +15,12 @@
|
|||
|
||||
const getPostTypeLabel = (postType: string) => {
|
||||
switch (postType) {
|
||||
case 'post': return 'Post'
|
||||
case 'essay': return 'Essay'
|
||||
default: return 'Post'
|
||||
case 'post':
|
||||
return 'Post'
|
||||
case 'essay':
|
||||
return 'Essay'
|
||||
default:
|
||||
return 'Post'
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -85,9 +88,7 @@
|
|||
{/if}
|
||||
|
||||
<div class="card-footer">
|
||||
<a href="/universe/{post.slug}" class="read-more">
|
||||
Read more →
|
||||
</a>
|
||||
<a href="/universe/{post.slug}" class="read-more"> Read more → </a>
|
||||
<UniverseIcon class="universe-icon" />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -40,7 +40,11 @@
|
|||
$effect(() => {
|
||||
if (initialData && mode === 'edit') {
|
||||
// Parse album content structure
|
||||
if (initialData.content && typeof initialData.content === 'object' && 'type' in initialData.content) {
|
||||
if (
|
||||
initialData.content &&
|
||||
typeof initialData.content === 'object' &&
|
||||
'type' in initialData.content
|
||||
) {
|
||||
const albumContent = initialData.content as any
|
||||
if (albumContent.type === 'album') {
|
||||
// Album content structure: { type: 'album', gallery: [mediaIds], description: JSONContent }
|
||||
|
|
@ -80,7 +84,7 @@
|
|||
})
|
||||
|
||||
const mediaResults = await Promise.all(mediaPromises)
|
||||
gallery = mediaResults.filter(media => media !== null)
|
||||
gallery = mediaResults.filter((media) => media !== null)
|
||||
} catch (error) {
|
||||
console.error('Failed to load gallery media:', error)
|
||||
}
|
||||
|
|
@ -114,9 +118,9 @@
|
|||
postType: 'album',
|
||||
status: newStatus,
|
||||
content,
|
||||
gallery: gallery.map(media => media.id),
|
||||
gallery: gallery.map((media) => media.id),
|
||||
featuredImage: gallery.length > 0 ? gallery[0].id : undefined,
|
||||
tags: tags.trim() ? tags.split(',').map(tag => tag.trim()) : []
|
||||
tags: tags.trim() ? tags.split(',').map((tag) => tag.trim()) : []
|
||||
}
|
||||
|
||||
const url = mode === 'edit' ? `/api/posts/${postId}` : '/api/posts'
|
||||
|
|
@ -197,19 +201,23 @@
|
|||
</div>
|
||||
<div class="header-actions">
|
||||
{#if mode === 'create'}
|
||||
<Button variant="secondary" onclick={handleCancel} disabled={isSaving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="secondary" onclick={() => handleSave('draft')} disabled={!isValid || isSaving}>
|
||||
<Button variant="secondary" onclick={handleCancel} disabled={isSaving}>Cancel</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onclick={() => handleSave('draft')}
|
||||
disabled={!isValid || isSaving}
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save Draft'}
|
||||
</Button>
|
||||
<Button variant="primary" onclick={() => handleSave('published')} disabled={!isValid || isSaving}>
|
||||
<Button
|
||||
variant="primary"
|
||||
onclick={() => handleSave('published')}
|
||||
disabled={!isValid || isSaving}
|
||||
>
|
||||
{isSaving ? 'Publishing...' : 'Publish Album'}
|
||||
</Button>
|
||||
{:else}
|
||||
<Button variant="secondary" onclick={handleCancel} disabled={isSaving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="secondary" onclick={handleCancel} disabled={isSaving}>Cancel</Button>
|
||||
<Button variant="primary" onclick={() => handleSave()} disabled={!isValid || isSaving}>
|
||||
{isSaving ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@
|
|||
// Get thumbnail - try cover photo first, then first photo
|
||||
function getThumbnailUrl(): string | null {
|
||||
if (album.coverPhotoId && album.photos.length > 0) {
|
||||
const coverPhoto = album.photos.find(p => p.id === album.coverPhotoId)
|
||||
const coverPhoto = album.photos.find((p) => p.id === album.coverPhotoId)
|
||||
if (coverPhoto) {
|
||||
return coverPhoto.thumbnailUrl || coverPhoto.url
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -17,12 +17,7 @@
|
|||
divider?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
isOpen = $bindable(),
|
||||
triggerElement,
|
||||
items,
|
||||
onClose
|
||||
}: Props = $props()
|
||||
let { isOpen = $bindable(), triggerElement, items, onClose }: Props = $props()
|
||||
|
||||
let dropdownElement: HTMLDivElement
|
||||
const dispatch = createEventDispatcher()
|
||||
|
|
|
|||
|
|
@ -212,7 +212,9 @@
|
|||
if (!clipboardData) return false
|
||||
|
||||
// Check for images first
|
||||
const imageItem = Array.from(clipboardData.items).find(item => item.type.indexOf('image') === 0)
|
||||
const imageItem = Array.from(clipboardData.items).find(
|
||||
(item) => item.type.indexOf('image') === 0
|
||||
)
|
||||
if (imageItem) {
|
||||
const file = imageItem.getAsFile()
|
||||
if (!file) return false
|
||||
|
|
|
|||
|
|
@ -264,18 +264,9 @@
|
|||
}}
|
||||
>
|
||||
<div class="form-section">
|
||||
<Input
|
||||
label="Title"
|
||||
bind:value={title}
|
||||
required
|
||||
placeholder="Essay title"
|
||||
/>
|
||||
<Input label="Title" bind:value={title} required placeholder="Essay title" />
|
||||
|
||||
<Input
|
||||
label="Slug"
|
||||
bind:value={slug}
|
||||
placeholder="essay-url-slug"
|
||||
/>
|
||||
<Input label="Slug" bind:value={slug} placeholder="essay-url-slug" />
|
||||
|
||||
<Input
|
||||
type="textarea"
|
||||
|
|
@ -366,7 +357,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
.admin-container {
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
|
|
@ -383,7 +373,6 @@
|
|||
display: flex;
|
||||
}
|
||||
|
||||
|
||||
// Custom styles for save/publish buttons to maintain grey color scheme
|
||||
:global(.save-button.btn-primary) {
|
||||
background-color: $grey-10;
|
||||
|
|
@ -517,7 +506,7 @@
|
|||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: $grey-20;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tag-input-wrapper {
|
||||
|
|
|
|||
|
|
@ -27,8 +27,8 @@
|
|||
|
||||
function handleImagesSelect(media: Media[]) {
|
||||
// Add new images to existing ones, avoiding duplicates
|
||||
const existingIds = new Set(value.map(item => item.id))
|
||||
const newImages = media.filter(item => !existingIds.has(item.id))
|
||||
const existingIds = new Set(value.map((item) => item.id))
|
||||
const newImages = media.filter((item) => !existingIds.has(item.id))
|
||||
|
||||
if (maxItems) {
|
||||
const availableSlots = maxItems - value.length
|
||||
|
|
@ -117,10 +117,8 @@
|
|||
// Computed properties
|
||||
const hasImages = $derived(value.length > 0)
|
||||
const canAddMore = $derived(!maxItems || value.length < maxItems)
|
||||
const selectedIds = $derived(value.map(item => item.id))
|
||||
const itemsText = $derived(
|
||||
value.length === 1 ? '1 image' : `${value.length} images`
|
||||
)
|
||||
const selectedIds = $derived(value.map((item) => item.id))
|
||||
const itemsText = $derived(value.length === 1 ? '1 image' : `${value.length} images`)
|
||||
</script>
|
||||
|
||||
<div class="gallery-manager">
|
||||
|
|
@ -158,13 +156,19 @@
|
|||
>
|
||||
<!-- Drag Handle -->
|
||||
<div class="drag-handle">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="9" cy="12" r="1" fill="currentColor"/>
|
||||
<circle cx="9" cy="5" r="1" fill="currentColor"/>
|
||||
<circle cx="9" cy="19" r="1" fill="currentColor"/>
|
||||
<circle cx="15" cy="12" r="1" fill="currentColor"/>
|
||||
<circle cx="15" cy="5" r="1" fill="currentColor"/>
|
||||
<circle cx="15" cy="19" r="1" fill="currentColor"/>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<circle cx="9" cy="12" r="1" fill="currentColor" />
|
||||
<circle cx="9" cy="5" r="1" fill="currentColor" />
|
||||
<circle cx="9" cy="19" r="1" fill="currentColor" />
|
||||
<circle cx="15" cy="12" r="1" fill="currentColor" />
|
||||
<circle cx="15" cy="5" r="1" fill="currentColor" />
|
||||
<circle cx="15" cy="19" r="1" fill="currentColor" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
|
|
@ -174,10 +178,29 @@
|
|||
<img src={item.thumbnailUrl} alt={item.filename} />
|
||||
{:else}
|
||||
<div class="image-placeholder">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="3" y="5" width="18" height="14" rx="2" stroke="currentColor" stroke-width="2"/>
|
||||
<circle cx="8.5" cy="8.5" r=".5" fill="currentColor"/>
|
||||
<path d="M3 16l5-5 3 3 4-4 4 4" stroke="currentColor" stroke-width="2" fill="none"/>
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect
|
||||
x="3"
|
||||
y="5"
|
||||
width="18"
|
||||
height="14"
|
||||
rx="2"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<circle cx="8.5" cy="8.5" r=".5" fill="currentColor" />
|
||||
<path
|
||||
d="M3 16l5-5 3 3 4-4 4 4"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -198,7 +221,13 @@
|
|||
onclick={() => removeImage(index)}
|
||||
aria-label="Remove image"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M6 6L18 18M6 18L18 6"
|
||||
stroke="currentColor"
|
||||
|
|
@ -217,13 +246,15 @@
|
|||
|
||||
<!-- Add More Button (if within grid) -->
|
||||
{#if canAddMore}
|
||||
<button
|
||||
type="button"
|
||||
class="add-more-item"
|
||||
onclick={openModal}
|
||||
>
|
||||
<button type="button" class="add-more-item" onclick={openModal}>
|
||||
<div class="add-icon">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12 5v14m-7-7h14"
|
||||
stroke="currentColor"
|
||||
|
|
@ -241,10 +272,24 @@
|
|||
<div class="empty-state" class:has-error={error}>
|
||||
<div class="empty-content">
|
||||
<div class="empty-icon">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="3" y="5" width="18" height="14" rx="2" stroke="currentColor" stroke-width="1.5"/>
|
||||
<circle cx="8.5" cy="8.5" r=".5" fill="currentColor"/>
|
||||
<path d="M3 16l5-5 3 3 4-4 4 4" stroke="currentColor" stroke-width="1.5" fill="none"/>
|
||||
<svg
|
||||
width="48"
|
||||
height="48"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect
|
||||
x="3"
|
||||
y="5"
|
||||
width="18"
|
||||
height="14"
|
||||
rx="2"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
<circle cx="8.5" cy="8.5" r=".5" fill="currentColor" />
|
||||
<path d="M3 16l5-5 3 3 4-4 4 4" stroke="currentColor" stroke-width="1.5" fill="none" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="empty-text">No images added yet</p>
|
||||
|
|
|
|||
|
|
@ -135,10 +135,7 @@
|
|||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as Node
|
||||
// Don't close if clicking inside the trigger button or the popover itself
|
||||
if (
|
||||
triggerElement?.contains(target) ||
|
||||
popoverElement?.contains(target)
|
||||
) {
|
||||
if (triggerElement?.contains(target) || popoverElement?.contains(target)) {
|
||||
return
|
||||
}
|
||||
onClose()
|
||||
|
|
@ -221,7 +218,7 @@
|
|||
label={field.label}
|
||||
bind:value={data.tagInput}
|
||||
onkeydown={(e) => e.key === 'Enter' && (e.preventDefault(), onAddTag())}
|
||||
placeholder={field.placeholder || "Add tags..."}
|
||||
placeholder={field.placeholder || 'Add tags...'}
|
||||
/>
|
||||
<button type="button" onclick={onAddTag} class="add-tag-btn">Add</button>
|
||||
|
||||
|
|
@ -459,7 +456,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
@include breakpoint('phone') {
|
||||
.metadata-popover {
|
||||
min-width: 280px;
|
||||
|
|
|
|||
|
|
@ -56,11 +56,9 @@
|
|||
!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>
|
||||
|
||||
|
|
@ -82,16 +80,12 @@
|
|||
tabindex="0"
|
||||
onclick={openModal}
|
||||
onkeydown={(e) => e.key === 'Enter' && openModal()}
|
||||
onmouseenter={() => isHovering = true}
|
||||
onmouseleave={() => isHovering = false}
|
||||
onmouseenter={() => (isHovering = true)}
|
||||
onmouseleave={() => (isHovering = false)}
|
||||
>
|
||||
{#if hasImage && value}
|
||||
<!-- Image Display -->
|
||||
<img
|
||||
src={value.url}
|
||||
alt={value.filename}
|
||||
class="preview-image"
|
||||
/>
|
||||
<img src={value.url} alt={value.filename} class="preview-image" />
|
||||
|
||||
<!-- Hover Overlay -->
|
||||
{#if isHovering}
|
||||
|
|
@ -149,10 +143,24 @@
|
|||
<!-- Empty State -->
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="3" y="5" width="18" height="14" rx="2" stroke="currentColor" stroke-width="1.5"/>
|
||||
<circle cx="8.5" cy="8.5" r=".5" fill="currentColor"/>
|
||||
<path d="M3 16l5-5 3 3 4-4 4 4" stroke="currentColor" stroke-width="1.5" fill="none"/>
|
||||
<svg
|
||||
width="48"
|
||||
height="48"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect
|
||||
x="3"
|
||||
y="5"
|
||||
width="18"
|
||||
height="14"
|
||||
rx="2"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
<circle cx="8.5" cy="8.5" r=".5" fill="currentColor" />
|
||||
<path d="M3 16l5-5 3 3 4-4 4 4" stroke="currentColor" stroke-width="1.5" fill="none" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="empty-text">{placeholder}</p>
|
||||
|
|
|
|||
|
|
@ -31,7 +31,8 @@
|
|||
const selectedMedia = Array.isArray(media) ? media[0] : media
|
||||
if (selectedMedia) {
|
||||
// Set a reasonable default width (max 600px)
|
||||
const displayWidth = selectedMedia.width && selectedMedia.width > 600 ? 600 : selectedMedia.width
|
||||
const displayWidth =
|
||||
selectedMedia.width && selectedMedia.width > 600 ? 600 : selectedMedia.width
|
||||
|
||||
editor
|
||||
.chain()
|
||||
|
|
@ -273,8 +274,12 @@
|
|||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
:global(.edra-image-placeholder-icon) {
|
||||
|
|
|
|||
|
|
@ -136,7 +136,6 @@
|
|||
isUploading = false
|
||||
uploadProgress = 0
|
||||
}, 500)
|
||||
|
||||
} catch (err) {
|
||||
isUploading = false
|
||||
uploadProgress = 0
|
||||
|
|
@ -276,7 +275,7 @@
|
|||
alt={value?.altText || value?.filename || 'Uploaded image'}
|
||||
containerWidth={100}
|
||||
loading="eager"
|
||||
aspectRatio={aspectRatio}
|
||||
{aspectRatio}
|
||||
class="preview-image"
|
||||
/>
|
||||
|
||||
|
|
@ -288,9 +287,28 @@
|
|||
</Button>
|
||||
|
||||
<Button variant="overlay" buttonSize="small" onclick={handleRemove}>
|
||||
<svg slot="icon" width="12" height="12" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<polyline points="3,6 5,6 21,6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M19 6V20A2 2 0 0 1 17 22H7A2 2 0 0 1 5 20V6M8 6V4A2 2 0 0 1 10 2H14A2 2 0 0 1 16 4V6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<svg
|
||||
slot="icon"
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<polyline
|
||||
points="3,6 5,6 21,6"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M19 6V20A2 2 0 0 1 17 22H7A2 2 0 0 1 5 20V6M8 6V4A2 2 0 0 1 10 2H14A2 2 0 0 1 16 4V6"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -331,7 +349,7 @@
|
|||
alt={value?.altText || value?.filename || 'Uploaded image'}
|
||||
containerWidth={800}
|
||||
loading="eager"
|
||||
aspectRatio={aspectRatio}
|
||||
{aspectRatio}
|
||||
class="preview-image"
|
||||
/>
|
||||
|
||||
|
|
@ -344,9 +362,28 @@
|
|||
</Button>
|
||||
|
||||
<Button variant="overlay" buttonSize="small" onclick={handleRemove}>
|
||||
<svg slot="icon" width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<polyline points="3,6 5,6 21,6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M19 6V20A2 2 0 0 1 17 22H7A2 2 0 0 1 5 20V6M8 6V4A2 2 0 0 1 10 2H14A2 2 0 0 1 16 4V6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<svg
|
||||
slot="icon"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<polyline
|
||||
points="3,6 5,6 21,6"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M19 6V20A2 2 0 0 1 17 22H7A2 2 0 0 1 5 20V6M8 6V4A2 2 0 0 1 10 2H14A2 2 0 0 1 16 4V6"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
Remove
|
||||
</Button>
|
||||
|
|
@ -365,7 +402,6 @@
|
|||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{:else}
|
||||
<!-- Upload Drop Zone -->
|
||||
<div
|
||||
|
|
@ -412,12 +448,53 @@
|
|||
{:else}
|
||||
<!-- Upload Prompt -->
|
||||
<div class="upload-prompt">
|
||||
<svg class="upload-icon" width="48" height="48" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14 2H6A2 2 0 0 0 4 4V20A2 2 0 0 0 6 22H18A2 2 0 0 0 20 20V8L14 2Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<polyline points="14,2 14,8 20,8" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<line x1="16" y1="13" x2="8" y2="13" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="16" y1="17" x2="8" y2="17" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
<polyline points="10,9 9,9 8,9" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<svg
|
||||
class="upload-icon"
|
||||
width="48"
|
||||
height="48"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M14 2H6A2 2 0 0 0 4 4V20A2 2 0 0 0 6 22H18A2 2 0 0 0 20 20V8L14 2Z"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<polyline
|
||||
points="14,2 14,8 20,8"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<line
|
||||
x1="16"
|
||||
y1="13"
|
||||
x2="8"
|
||||
y2="13"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<line
|
||||
x1="16"
|
||||
y1="17"
|
||||
x2="8"
|
||||
y2="17"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<polyline
|
||||
points="10,9 9,9 8,9"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
<p class="upload-main-text">{placeholder}</p>
|
||||
<p class="upload-sub-text">
|
||||
|
|
@ -432,14 +509,10 @@
|
|||
<!-- Action Buttons -->
|
||||
{#if !hasValue && !isUploading}
|
||||
<div class="action-buttons">
|
||||
<Button variant="primary" onclick={handleBrowseClick}>
|
||||
Choose File
|
||||
</Button>
|
||||
<Button variant="primary" onclick={handleBrowseClick}>Choose File</Button>
|
||||
|
||||
{#if showBrowseLibrary}
|
||||
<Button variant="ghost" onclick={handleBrowseLibrary}>
|
||||
Browse Library
|
||||
</Button>
|
||||
<Button variant="ghost" onclick={handleBrowseLibrary}>Browse Library</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -13,12 +13,7 @@
|
|||
onUpdate: (updatedMedia: Media) => void
|
||||
}
|
||||
|
||||
let {
|
||||
isOpen = $bindable(),
|
||||
media,
|
||||
onClose,
|
||||
onUpdate
|
||||
}: Props = $props()
|
||||
let { isOpen = $bindable(), media, onClose, onUpdate }: Props = $props()
|
||||
|
||||
// Form state
|
||||
let altText = $state('')
|
||||
|
|
@ -29,14 +24,16 @@
|
|||
let successMessage = $state('')
|
||||
|
||||
// Usage tracking state
|
||||
let usage = $state<Array<{
|
||||
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
|
||||
|
|
@ -115,7 +112,6 @@
|
|||
setTimeout(() => {
|
||||
handleClose()
|
||||
}, 1500)
|
||||
|
||||
} catch (err) {
|
||||
error = 'Failed to update media. Please try again.'
|
||||
console.error('Failed to update media:', err)
|
||||
|
|
@ -125,7 +121,10 @@
|
|||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!media || !confirm('Are you sure you want to delete this media file? This action cannot be undone.')) {
|
||||
if (
|
||||
!media ||
|
||||
!confirm('Are you sure you want to delete this media file? This action cannot be undone.')
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -144,7 +143,6 @@
|
|||
// Close modal and let parent handle the deletion
|
||||
handleClose()
|
||||
// Note: Parent component should refresh the media list
|
||||
|
||||
} catch (err) {
|
||||
error = 'Failed to delete media. Please try again.'
|
||||
console.error('Failed to delete media:', err)
|
||||
|
|
@ -155,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>
|
||||
|
|
@ -291,7 +326,8 @@
|
|||
<span class="toggle-slider"></span>
|
||||
<div class="toggle-content">
|
||||
<span class="toggle-title">Photography</span>
|
||||
<span class="toggle-description">Show this media in the photography experience</span>
|
||||
<span class="toggle-description">Show this media in the photography experience</span
|
||||
>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
|
@ -311,7 +347,12 @@
|
|||
<div class="usage-content">
|
||||
<div class="usage-header">
|
||||
{#if usageItem.contentUrl}
|
||||
<a href={usageItem.contentUrl} class="usage-title" target="_blank" rel="noopener">
|
||||
<a
|
||||
href={usageItem.contentUrl}
|
||||
class="usage-title"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
{usageItem.contentTitle}
|
||||
</a>
|
||||
{:else}
|
||||
|
|
@ -321,7 +362,9 @@
|
|||
</div>
|
||||
<div class="usage-details">
|
||||
<span class="usage-field">{usageItem.fieldDisplayName}</span>
|
||||
<span class="usage-date">Added {new Date(usageItem.createdAt).toLocaleDateString()}</span>
|
||||
<span class="usage-date"
|
||||
>Added {new Date(usageItem.createdAt).toLocaleDateString()}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
|
@ -337,12 +380,7 @@
|
|||
<!-- Footer -->
|
||||
<div class="modal-footer">
|
||||
<div class="footer-left">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onclick={handleDelete}
|
||||
disabled={isSaving}
|
||||
class="delete-button"
|
||||
>
|
||||
<Button variant="ghost" onclick={handleDelete} disabled={isSaving} class="delete-button">
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -355,9 +393,7 @@
|
|||
<span class="success-text">{successMessage}</span>
|
||||
{/if}
|
||||
|
||||
<Button variant="ghost" onclick={handleClose} disabled={isSaving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="ghost" onclick={handleClose} disabled={isSaving}>Cancel</Button>
|
||||
<Button variant="primary" onclick={handleSave} disabled={isSaving}>
|
||||
{isSaving ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
|
|
@ -711,8 +747,12 @@
|
|||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive adjustments
|
||||
|
|
|
|||
|
|
@ -75,17 +75,17 @@
|
|||
: mode === 'single' && value && !Array.isArray(value)
|
||||
? [value.id]
|
||||
: mode === 'multiple' && Array.isArray(value)
|
||||
? value.map(item => item.id)
|
||||
? value.map((item) => item.id)
|
||||
: []
|
||||
)
|
||||
|
||||
const modalTitle = $derived(
|
||||
mode === 'single' ? `Select ${fileType === 'image' ? 'Image' : 'Media'}` : `Select ${fileType === 'image' ? 'Images' : 'Media'}`
|
||||
mode === 'single'
|
||||
? `Select ${fileType === 'image' ? 'Image' : 'Media'}`
|
||||
: `Select ${fileType === 'image' ? 'Images' : 'Media'}`
|
||||
)
|
||||
|
||||
const confirmText = $derived(
|
||||
mode === 'single' ? 'Select' : 'Select Files'
|
||||
)
|
||||
const confirmText = $derived(mode === 'single' ? 'Select' : 'Select Files')
|
||||
</script>
|
||||
|
||||
<div class="media-input">
|
||||
|
|
@ -106,10 +106,29 @@
|
|||
<img src={value.thumbnailUrl} alt={value.filename} />
|
||||
{:else}
|
||||
<div class="media-placeholder">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="3" y="5" width="18" height="14" rx="2" stroke="currentColor" stroke-width="2"/>
|
||||
<circle cx="8.5" cy="8.5" r=".5" fill="currentColor"/>
|
||||
<path d="M3 16l5-5 3 3 4-4 4 4" stroke="currentColor" stroke-width="2" fill="none"/>
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect
|
||||
x="3"
|
||||
y="5"
|
||||
width="18"
|
||||
height="14"
|
||||
rx="2"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<circle cx="8.5" cy="8.5" r=".5" fill="currentColor" />
|
||||
<path
|
||||
d="M3 16l5-5 3 3 4-4 4 4"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -133,10 +152,29 @@
|
|||
<img src={item.thumbnailUrl} alt={item.filename} />
|
||||
{:else}
|
||||
<div class="media-placeholder">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="3" y="5" width="18" height="14" rx="2" stroke="currentColor" stroke-width="2"/>
|
||||
<circle cx="8.5" cy="8.5" r=".5" fill="currentColor"/>
|
||||
<path d="M3 16l5-5 3 3 4-4 4 4" stroke="currentColor" stroke-width="2" fill="none"/>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect
|
||||
x="3"
|
||||
y="5"
|
||||
width="18"
|
||||
height="14"
|
||||
rx="2"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<circle cx="8.5" cy="8.5" r=".5" fill="currentColor" />
|
||||
<path
|
||||
d="M3 16l5-5 3 3 4-4 4 4"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -168,9 +206,7 @@
|
|||
class:placeholder={!hasValue}
|
||||
/>
|
||||
<div class="input-actions">
|
||||
<Button variant="ghost" onclick={openModal}>
|
||||
Browse
|
||||
</Button>
|
||||
<Button variant="ghost" onclick={openModal}>Browse</Button>
|
||||
{#if hasValue}
|
||||
<Button variant="ghost" onclick={handleClear} aria-label="Clear selection">
|
||||
<svg
|
||||
|
|
@ -205,7 +241,7 @@
|
|||
{fileType}
|
||||
{selectedIds}
|
||||
title={modalTitle}
|
||||
confirmText={confirmText}
|
||||
{confirmText}
|
||||
onselect={handleMediaSelect}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -60,7 +60,9 @@
|
|||
const selectionCount = $derived(selectedMedia.length)
|
||||
const footerText = $derived(
|
||||
mode === 'single'
|
||||
? canConfirm ? '1 item selected' : 'No item selected'
|
||||
? canConfirm
|
||||
? '1 item selected'
|
||||
: 'No item selected'
|
||||
: `${selectionCount} item${selectionCount !== 1 ? 's' : ''} selected`
|
||||
)
|
||||
</script>
|
||||
|
|
@ -117,14 +119,8 @@
|
|||
<span class="selection-count">{footerText}</span>
|
||||
</div>
|
||||
<div class="footer-actions">
|
||||
<Button variant="ghost" onclick={handleCancel} disabled={isLoading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onclick={handleConfirm}
|
||||
disabled={!canConfirm || isLoading}
|
||||
>
|
||||
<Button variant="ghost" onclick={handleCancel} disabled={isLoading}>Cancel</Button>
|
||||
<Button variant="primary" onclick={handleConfirm} disabled={!canConfirm || isLoading}>
|
||||
{confirmText}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -12,12 +12,7 @@
|
|||
loading?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
mode,
|
||||
fileType = 'all',
|
||||
selectedIds = [],
|
||||
loading = $bindable(false)
|
||||
}: Props = $props()
|
||||
let { mode, fileType = 'all', selectedIds = [], loading = $bindable(false) }: Props = $props()
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
select: Media[]
|
||||
|
|
@ -37,7 +32,7 @@
|
|||
// Initialize selected media from IDs
|
||||
$effect(() => {
|
||||
if (selectedIds.length > 0 && media.length > 0) {
|
||||
selectedMedia = media.filter(item => selectedIds.includes(item.id))
|
||||
selectedMedia = media.filter((item) => selectedIds.includes(item.id))
|
||||
dispatch('select', selectedMedia)
|
||||
}
|
||||
})
|
||||
|
|
@ -112,7 +107,6 @@
|
|||
currentPage = page
|
||||
totalPages = data.pagination.totalPages
|
||||
total = data.pagination.total
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading media:', error)
|
||||
} finally {
|
||||
|
|
@ -125,10 +119,10 @@
|
|||
selectedMedia = [item]
|
||||
dispatch('select', selectedMedia)
|
||||
} else {
|
||||
const isSelected = selectedMedia.some(m => m.id === item.id)
|
||||
const isSelected = selectedMedia.some((m) => m.id === item.id)
|
||||
|
||||
if (isSelected) {
|
||||
selectedMedia = selectedMedia.filter(m => m.id !== item.id)
|
||||
selectedMedia = selectedMedia.filter((m) => m.id !== item.id)
|
||||
} else {
|
||||
selectedMedia = [...selectedMedia, item]
|
||||
}
|
||||
|
|
@ -161,7 +155,7 @@
|
|||
}
|
||||
|
||||
function isSelected(item: Media): boolean {
|
||||
return selectedMedia.some(m => m.id === item.id)
|
||||
return selectedMedia.some((m) => m.id === item.id)
|
||||
}
|
||||
|
||||
// Computed properties
|
||||
|
|
@ -174,11 +168,7 @@
|
|||
<!-- Search and Filter Controls -->
|
||||
<div class="controls">
|
||||
<div class="search-filters">
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search media files..."
|
||||
bind:value={searchQuery}
|
||||
/>
|
||||
<Input type="search" placeholder="Search media files..." bind:value={searchQuery} />
|
||||
|
||||
<select bind:value={filterType} class="filter-select">
|
||||
<option value="all">All Files</option>
|
||||
|
|
@ -216,10 +206,16 @@
|
|||
</div>
|
||||
{:else if media.length === 0}
|
||||
<div class="empty-state">
|
||||
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="3" y="5" width="18" height="14" rx="2" stroke="currentColor" stroke-width="2"/>
|
||||
<circle cx="8.5" cy="8.5" r=".5" fill="currentColor"/>
|
||||
<path d="M3 16l5-5 3 3 4-4 4 4" stroke="currentColor" stroke-width="2" fill="none"/>
|
||||
<svg
|
||||
width="64"
|
||||
height="64"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect x="3" y="5" width="18" height="14" rx="2" stroke="currentColor" stroke-width="2" />
|
||||
<circle cx="8.5" cy="8.5" r=".5" fill="currentColor" />
|
||||
<path d="M3 16l5-5 3 3 4-4 4 4" stroke="currentColor" stroke-width="2" fill="none" />
|
||||
</svg>
|
||||
<h3>No media found</h3>
|
||||
<p>Try adjusting your search or upload some files</p>
|
||||
|
|
@ -237,16 +233,35 @@
|
|||
<div class="media-thumbnail">
|
||||
{#if item.mimeType?.startsWith('image/')}
|
||||
<img
|
||||
src={item.mimeType === 'image/svg+xml' ? item.url : (item.thumbnailUrl || item.url)}
|
||||
src={item.mimeType === 'image/svg+xml' ? item.url : item.thumbnailUrl || item.url}
|
||||
alt={item.filename}
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else}
|
||||
<div class="media-placeholder">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="3" y="5" width="18" height="14" rx="2" stroke="currentColor" stroke-width="2"/>
|
||||
<circle cx="8.5" cy="8.5" r=".5" fill="currentColor"/>
|
||||
<path d="M3 16l5-5 3 3 4-4 4 4" stroke="currentColor" stroke-width="2" fill="none"/>
|
||||
<svg
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect
|
||||
x="3"
|
||||
y="5"
|
||||
width="18"
|
||||
height="14"
|
||||
rx="2"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<circle cx="8.5" cy="8.5" r=".5" fill="currentColor" />
|
||||
<path
|
||||
d="M3 16l5-5 3 3 4-4 4 4"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -254,19 +269,27 @@
|
|||
<!-- Selection Indicator -->
|
||||
{#if mode === 'multiple'}
|
||||
<div class="selection-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected(item)}
|
||||
readonly
|
||||
/>
|
||||
<input type="checkbox" checked={isSelected(item)} readonly />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Selected Overlay -->
|
||||
{#if isSelected(item)}
|
||||
<div class="selected-overlay">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9 12l2 2 4-4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M9 12l2 2 4-4"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -280,8 +303,17 @@
|
|||
<div class="media-indicators">
|
||||
{#if item.isPhotography}
|
||||
<span class="indicator-pill photography" title="Photography">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<polygon points="12,2 15.09,8.26 22,9 17,14.74 18.18,21.02 12,17.77 5.82,21.02 7,14.74 2,9 8.91,8.26" fill="currentColor"/>
|
||||
<svg
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<polygon
|
||||
points="12,2 15.09,8.26 22,9 17,14.74 18.18,21.02 12,17.77 5.82,21.02 7,14.74 2,9 8.91,8.26"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
Photo
|
||||
</span>
|
||||
|
|
@ -291,9 +323,7 @@
|
|||
Alt
|
||||
</span>
|
||||
{:else}
|
||||
<span class="indicator-pill no-alt-text" title="No alt text">
|
||||
No Alt
|
||||
</span>
|
||||
<span class="indicator-pill no-alt-text" title="No alt text"> No Alt </span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="media-meta">
|
||||
|
|
@ -310,12 +340,7 @@
|
|||
<!-- Load More Button -->
|
||||
{#if hasMore}
|
||||
<div class="load-more-container">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onclick={loadMore}
|
||||
disabled={loading}
|
||||
class="load-more-button"
|
||||
>
|
||||
<Button variant="ghost" onclick={loadMore} disabled={loading} class="load-more-button">
|
||||
{#if loading}
|
||||
<LoadingSpinner buttonSize="small" />
|
||||
Loading...
|
||||
|
|
|
|||
|
|
@ -258,7 +258,12 @@
|
|||
<div class="file-list-header">
|
||||
<h3>Files to Upload</h3>
|
||||
<div class="file-actions">
|
||||
<Button variant="secondary" buttonSize="small" onclick={clearAll} disabled={isUploading}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
buttonSize="small"
|
||||
onclick={clearAll}
|
||||
disabled={isUploading}
|
||||
>
|
||||
Clear All
|
||||
</Button>
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -132,11 +132,7 @@
|
|||
<div class="popover-content">
|
||||
<h3>Post Settings</h3>
|
||||
|
||||
<Input
|
||||
label="Slug"
|
||||
bind:value={slug}
|
||||
placeholder="post-slug"
|
||||
/>
|
||||
<Input label="Slug" bind:value={slug} placeholder="post-slug" />
|
||||
|
||||
{#if postType === 'essay'}
|
||||
<Input
|
||||
|
|
|
|||
|
|
@ -119,7 +119,12 @@
|
|||
status,
|
||||
content: editorContent,
|
||||
featuredImage: featuredImage.url,
|
||||
tags: tags ? tags.split(',').map(tag => tag.trim()).filter(Boolean) : [],
|
||||
tags: tags
|
||||
? tags
|
||||
.split(',')
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean)
|
||||
: [],
|
||||
excerpt: generateExcerpt(editorContent)
|
||||
}
|
||||
|
||||
|
|
@ -147,7 +152,6 @@
|
|||
} else {
|
||||
goto('/admin/posts')
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
error = `Failed to ${mode === 'edit' ? 'update' : 'create'} photo post`
|
||||
console.error(err)
|
||||
|
|
@ -193,13 +197,19 @@
|
|||
|
||||
<div class="header-actions">
|
||||
{#if !isSaving}
|
||||
<Button variant="ghost" onclick={() => goto('/admin/posts')}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="secondary" onclick={handleDraft} disabled={!featuredImage || !title.trim()}>
|
||||
<Button variant="ghost" onclick={() => goto('/admin/posts')}>Cancel</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onclick={handleDraft}
|
||||
disabled={!featuredImage || !title.trim()}
|
||||
>
|
||||
Save Draft
|
||||
</Button>
|
||||
<Button variant="primary" onclick={handlePublish} disabled={!featuredImage || !title.trim()}>
|
||||
<Button
|
||||
variant="primary"
|
||||
onclick={handlePublish}
|
||||
disabled={!featuredImage || !title.trim()}
|
||||
>
|
||||
{isSaving ? 'Publishing...' : 'Publish'}
|
||||
</Button>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -48,13 +48,17 @@
|
|||
label: 'Slug',
|
||||
placeholder: 'post-slug'
|
||||
},
|
||||
...(postType === 'essay' ? [{
|
||||
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',
|
||||
|
|
|
|||
|
|
@ -108,7 +108,6 @@
|
|||
formData.caseStudyContent = content
|
||||
}
|
||||
|
||||
|
||||
async function handleSave() {
|
||||
// Check if we're on the case study tab and should save editor content
|
||||
if (activeTab === 'case-study' && editorRef) {
|
||||
|
|
@ -134,7 +133,6 @@
|
|||
return
|
||||
}
|
||||
|
||||
|
||||
const payload = {
|
||||
title: formData.title,
|
||||
subtitle: formData.subtitle,
|
||||
|
|
@ -236,7 +234,11 @@
|
|||
dropdownActions={[
|
||||
{ label: 'Save as Draft', status: 'draft' },
|
||||
{ label: 'List Only', status: 'list-only', show: formData.status !== 'list-only' },
|
||||
{ label: 'Password Protected', status: 'password-protected', show: formData.status !== 'password-protected' }
|
||||
{
|
||||
label: 'Password Protected',
|
||||
status: 'password-protected',
|
||||
show: formData.status !== 'password-protected'
|
||||
}
|
||||
]}
|
||||
/>
|
||||
{/if}
|
||||
|
|
@ -351,8 +353,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
.tab-panels {
|
||||
position: relative;
|
||||
|
||||
|
|
|
|||
|
|
@ -85,7 +85,6 @@
|
|||
document.addEventListener('closeDropdowns', handleCloseDropdowns)
|
||||
return () => document.removeEventListener('closeDropdowns', handleCloseDropdowns)
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<div
|
||||
|
|
@ -115,11 +114,7 @@
|
|||
</div>
|
||||
|
||||
<div class="dropdown-container">
|
||||
<button
|
||||
class="action-button"
|
||||
onclick={handleToggleDropdown}
|
||||
aria-label="Project actions"
|
||||
>
|
||||
<button class="action-button" onclick={handleToggleDropdown} aria-label="Project actions">
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
|
|
@ -201,7 +196,6 @@
|
|||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
|
||||
.dropdown-container {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
|
|
@ -265,5 +259,4 @@
|
|||
background-color: $grey-90;
|
||||
margin: $unit-half 0;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
@ -229,7 +229,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
.composer-container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
|
|
|
|||
|
|
@ -60,9 +60,7 @@
|
|||
})
|
||||
|
||||
const availableActions = $derived(
|
||||
dropdownActions.filter(action =>
|
||||
action.show !== false && action.status !== currentStatus
|
||||
)
|
||||
dropdownActions.filter((action) => action.show !== false && action.status !== currentStatus)
|
||||
)
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -22,8 +22,7 @@
|
|||
|
||||
import { Extension, type Range, type Dispatch } from '@tiptap/core'
|
||||
import { Decoration, DecorationSet } from '@tiptap/pm/view'
|
||||
import { Plugin, PluginKey, type EditorState, type Transaction } from '@tiptap/pm/state'
|
||||
import { Node as PMNode } from '@tiptap/pm/model'
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
|
|
|
|||
|
|
@ -13,12 +13,16 @@ declare module '@tiptap/core' {
|
|||
/**
|
||||
* Insert a gallery
|
||||
*/
|
||||
setGallery: (options: { images: Array<{ id: number; url: string; alt?: string; title?: string }> }) => ReturnType
|
||||
setGallery: (options: {
|
||||
images: Array<{ id: number; url: string; alt?: string; title?: string }>
|
||||
}) => ReturnType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const GalleryExtended = (component: Component<NodeViewProps>): Node<GalleryOptions, unknown> => {
|
||||
export const GalleryExtended = (
|
||||
component: Component<NodeViewProps>
|
||||
): Node<GalleryOptions, unknown> => {
|
||||
return Node.create<GalleryOptions>({
|
||||
name: 'gallery',
|
||||
|
||||
|
|
@ -46,15 +50,16 @@ export const GalleryExtended = (component: Component<NodeViewProps>): Node<Galle
|
|||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{ tag: `div[data-type="${this.name}"]` }
|
||||
]
|
||||
return [{ tag: `div[data-type="${this.name}"]` }]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ['div', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
|
||||
'data-type': this.name
|
||||
})]
|
||||
return [
|
||||
'div',
|
||||
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
|
||||
'data-type': this.name
|
||||
})
|
||||
]
|
||||
},
|
||||
|
||||
group: 'block',
|
||||
|
|
@ -67,14 +72,16 @@ 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
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
import { Editor, findParentNode } from '@tiptap/core'
|
||||
import { EditorState, Selection, Transaction } from '@tiptap/pm/state'
|
||||
import { CellSelection, type Rect, TableMap } from '@tiptap/pm/tables'
|
||||
import { Node, ResolvedPos } from '@tiptap/pm/model'
|
||||
import type { EditorView } from '@tiptap/pm/view'
|
||||
import Table from './table.js'
|
||||
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@
|
|||
|
||||
function handleMediaSelect(media: Media | Media[]) {
|
||||
const mediaArray = Array.isArray(media) ? media : [media]
|
||||
const newImages = mediaArray.map(m => ({
|
||||
const newImages = mediaArray.map((m) => ({
|
||||
id: m.id,
|
||||
url: m.url,
|
||||
alt: m.altText || '',
|
||||
|
|
@ -40,7 +40,7 @@
|
|||
// Add to existing images
|
||||
const existingImages = node.attrs.images || []
|
||||
const currentIds = existingImages.map((img: any) => img.id)
|
||||
const uniqueNewImages = newImages.filter(img => !currentIds.includes(img.id))
|
||||
const uniqueNewImages = newImages.filter((img) => !currentIds.includes(img.id))
|
||||
updateAttributes({ images: [...existingImages, ...uniqueNewImages] })
|
||||
}
|
||||
|
||||
|
|
@ -87,12 +87,7 @@
|
|||
<div class={`edra-gallery-grid ${layout === 'masonry' ? 'masonry' : 'grid'}`}>
|
||||
{#each images as image}
|
||||
<div class="edra-gallery-item">
|
||||
<img
|
||||
src={image.url}
|
||||
alt={image.alt}
|
||||
title={image.title}
|
||||
loading="lazy"
|
||||
/>
|
||||
<img src={image.url} alt={image.alt} title={image.title} loading="lazy" />
|
||||
{#if editor?.isEditable}
|
||||
<button
|
||||
class="edra-gallery-item-remove"
|
||||
|
|
@ -141,18 +136,10 @@
|
|||
</div>
|
||||
|
||||
<div class="edra-gallery-toolbar-section">
|
||||
<button
|
||||
class="edra-toolbar-button"
|
||||
onclick={handleAddImages}
|
||||
title="Add Images"
|
||||
>
|
||||
<button class="edra-toolbar-button" onclick={handleAddImages} title="Add Images">
|
||||
<Plus />
|
||||
</button>
|
||||
<button
|
||||
class="edra-toolbar-button"
|
||||
onclick={handleEditGallery}
|
||||
title="Edit Gallery"
|
||||
>
|
||||
<button class="edra-toolbar-button" onclick={handleEditGallery} title="Edit Gallery">
|
||||
<Edit />
|
||||
</button>
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@
|
|||
function handleMediaSelect(media: Media | Media[]) {
|
||||
const mediaArray = Array.isArray(media) ? media : [media]
|
||||
if (mediaArray.length > 0) {
|
||||
const galleryImages = mediaArray.map(m => ({
|
||||
const galleryImages = mediaArray.map((m) => ({
|
||||
id: m.id,
|
||||
url: m.url,
|
||||
alt: m.altText || '',
|
||||
|
|
@ -225,8 +225,12 @@
|
|||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
:global(.edra-gallery-placeholder-icon) {
|
||||
|
|
|
|||
|
|
@ -28,11 +28,15 @@
|
|||
function handleMediaSelect(media: Media | Media[]) {
|
||||
const selectedMedia = Array.isArray(media) ? media[0] : media
|
||||
if (selectedMedia) {
|
||||
editor.chain().focus().setImage({
|
||||
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.')
|
||||
|
|
@ -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,11 +216,7 @@ export function getResponsiveUrls(publicId: string): Record<string, string> {
|
|||
}
|
||||
|
||||
// Smart image size selection based on container width
|
||||
export function getSmartImageUrl(
|
||||
publicId: string,
|
||||
containerWidth: number,
|
||||
retina = true
|
||||
): string {
|
||||
export function getSmartImageUrl(publicId: string, containerWidth: number, retina = true): string {
|
||||
// Account for retina displays
|
||||
const targetWidth = retina ? containerWidth * 2 : containerWidth
|
||||
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ export async function trackMediaUsage(references: MediaUsageReference[]) {
|
|||
if (references.length === 0) return
|
||||
|
||||
// Use upsert to handle duplicates gracefully
|
||||
const operations = references.map(ref =>
|
||||
const operations = references.map((ref) =>
|
||||
prisma.mediaUsage.upsert({
|
||||
where: {
|
||||
mediaId_contentType_contentId_fieldName: {
|
||||
|
|
@ -84,7 +84,7 @@ export async function updateMediaUsage(
|
|||
// Add new usage references
|
||||
if (mediaIds.length > 0) {
|
||||
await tx.mediaUsage.createMany({
|
||||
data: mediaIds.map(mediaId => ({
|
||||
data: mediaIds.map((mediaId) => ({
|
||||
mediaId,
|
||||
contentType,
|
||||
contentId,
|
||||
|
|
@ -170,13 +170,13 @@ export async function getMediaUsage(mediaId: number): Promise<MediaUsageDisplay[
|
|||
*/
|
||||
function getFieldDisplayName(fieldName: string): string {
|
||||
const displayNames: Record<string, string> = {
|
||||
'featuredImage': 'Featured Image',
|
||||
'logoUrl': 'Logo',
|
||||
'gallery': 'Gallery',
|
||||
'content': 'Content',
|
||||
'coverPhotoId': 'Cover Photo',
|
||||
'photoId': 'Photo',
|
||||
'attachments': 'Attachments'
|
||||
featuredImage: 'Featured Image',
|
||||
logoUrl: 'Logo',
|
||||
gallery: 'Gallery',
|
||||
content: 'Content',
|
||||
coverPhotoId: 'Cover Photo',
|
||||
photoId: 'Photo',
|
||||
attachments: 'Attachments'
|
||||
}
|
||||
|
||||
return displayNames[fieldName] || fieldName
|
||||
|
|
@ -195,8 +195,8 @@ export function extractMediaIds(data: any, fieldName: string): number[] {
|
|||
// Gallery/attachments are arrays of media objects with id property
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map(item => typeof item === 'object' ? item.id : parseInt(item))
|
||||
.filter(id => !isNaN(id))
|
||||
.map((item) => (typeof item === 'object' ? item.id : parseInt(item)))
|
||||
.filter((id) => !isNaN(id))
|
||||
}
|
||||
return []
|
||||
|
||||
|
|
|
|||
|
|
@ -59,7 +59,9 @@ async function fetchRecentPSNGames(fetch: typeof window.fetch): Promise<Serializ
|
|||
async function fetchProjects(
|
||||
fetch: typeof window.fetch
|
||||
): Promise<{ projects: Project[]; pagination: any }> {
|
||||
const response = await fetch('/api/projects?projectType=work&includeListOnly=true&includePasswordProtected=true')
|
||||
const response = await fetch(
|
||||
'/api/projects?projectType=work&includeListOnly=true&includePasswordProtected=true'
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch projects: ${response.status}`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -238,7 +238,8 @@
|
|||
{#if photographyFilter === 'all'}
|
||||
No albums found. Create your first album!
|
||||
{:else}
|
||||
No albums found matching the current filters. Try adjusting your filters or create a new album.
|
||||
No albums found matching the current filters. Try adjusting your filters or create a new
|
||||
album.
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -577,7 +577,7 @@
|
|||
triggerElement={metadataButtonElement}
|
||||
onUpdate={handleMetadataUpdate}
|
||||
onDelete={handleMetadataDelete}
|
||||
onClose={() => isMetadataOpen = false}
|
||||
onClose={() => (isMetadataOpen = false)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -258,7 +258,7 @@
|
|||
triggerElement={metadataButtonElement}
|
||||
onUpdate={handleMetadataUpdate}
|
||||
onDelete={() => {}}
|
||||
onClose={() => isMetadataOpen = false}
|
||||
onClose={() => (isMetadataOpen = false)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -30,22 +30,42 @@
|
|||
<div class="button-group">
|
||||
<Button buttonSize="small" iconOnly>
|
||||
<svg slot="icon" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M8 4v8m4-4H4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<path
|
||||
d="M8 4v8m4-4H4"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
<Button buttonSize="medium" iconOnly>
|
||||
<svg slot="icon" width="18" height="18" viewBox="0 0 18 18" fill="none">
|
||||
<path d="M9 5v8m4-4H5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<path
|
||||
d="M9 5v8m4-4H5"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
<Button buttonSize="large" iconOnly>
|
||||
<svg slot="icon" width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<path d="M10 6v8m4-4H6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<path
|
||||
d="M10 6v8m4-4H6"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
<Button buttonSize="icon" iconOnly variant="ghost">
|
||||
<svg slot="icon" width="18" height="18" viewBox="0 0 18 18" fill="none">
|
||||
<path d="M6 6l6 6m0-6l-6 6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<path
|
||||
d="M6 6l6 6m0-6l-6 6"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -56,14 +76,25 @@
|
|||
<div class="button-group">
|
||||
<Button>
|
||||
<svg slot="icon" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M8 4v8m4-4H4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<path
|
||||
d="M8 4v8m4-4H4"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
Add Item
|
||||
</Button>
|
||||
<Button iconPosition="right" variant="secondary">
|
||||
Next
|
||||
<svg slot="icon" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M6 4l4 4-4 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path
|
||||
d="M6 4l4 4-4 4"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -130,11 +130,7 @@
|
|||
<p>Multiple image management with drag-and-drop reordering.</p>
|
||||
|
||||
<div class="form-column">
|
||||
<GalleryManager
|
||||
label="Image Gallery"
|
||||
bind:value={galleryImages}
|
||||
showFileInfo={false}
|
||||
/>
|
||||
<GalleryManager label="Image Gallery" bind:value={galleryImages} showFileInfo={false} />
|
||||
|
||||
<GalleryManager
|
||||
label="Project Gallery (Max 6 images)"
|
||||
|
|
@ -149,12 +145,8 @@
|
|||
<section class="test-section">
|
||||
<h2>Form Actions</h2>
|
||||
<div class="actions-grid">
|
||||
<Button variant="primary" onclick={logAllValues}>
|
||||
Log All Values
|
||||
</Button>
|
||||
<Button variant="ghost" onclick={clearAllValues}>
|
||||
Clear All
|
||||
</Button>
|
||||
<Button variant="primary" onclick={logAllValues}>Log All Values</Button>
|
||||
<Button variant="ghost" onclick={clearAllValues}>Clear All</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
|
@ -169,7 +161,11 @@
|
|||
|
||||
<div class="value-item">
|
||||
<h4>Multiple Media ({multipleMedia.length}):</h4>
|
||||
<pre>{JSON.stringify(multipleMedia.map(m => m.filename), null, 2)}</pre>
|
||||
<pre>{JSON.stringify(
|
||||
multipleMedia.map((m) => m.filename),
|
||||
null,
|
||||
2
|
||||
)}</pre>
|
||||
</div>
|
||||
|
||||
<div class="value-item">
|
||||
|
|
@ -179,12 +175,20 @@
|
|||
|
||||
<div class="value-item">
|
||||
<h4>Gallery Images ({galleryImages.length}):</h4>
|
||||
<pre>{JSON.stringify(galleryImages.map(m => m.filename), null, 2)}</pre>
|
||||
<pre>{JSON.stringify(
|
||||
galleryImages.map((m) => m.filename),
|
||||
null,
|
||||
2
|
||||
)}</pre>
|
||||
</div>
|
||||
|
||||
<div class="value-item">
|
||||
<h4>Project Gallery ({projectGallery.length}):</h4>
|
||||
<pre>{JSON.stringify(projectGallery.map(m => m.filename), null, 2)}</pre>
|
||||
<pre>{JSON.stringify(
|
||||
projectGallery.map((m) => m.filename),
|
||||
null,
|
||||
2
|
||||
)}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -39,7 +39,6 @@
|
|||
|
||||
<AdminPage title="ImageUploader Test" subtitle="Test the new direct upload functionality">
|
||||
<div class="test-container">
|
||||
|
||||
<!-- Basic Image Upload -->
|
||||
<section class="test-section">
|
||||
<h2>Basic Image Upload</h2>
|
||||
|
|
@ -95,9 +94,7 @@
|
|||
<button type="button" class="btn btn-primary" onclick={logAllValues}>
|
||||
Log All Values
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost" onclick={clearAll}>
|
||||
Clear All
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost" onclick={clearAll}> Clear All </button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
|
@ -107,36 +104,53 @@
|
|||
<div class="values-display">
|
||||
<div class="value-item">
|
||||
<h4>Single Image:</h4>
|
||||
<pre>{JSON.stringify(singleImage ? {
|
||||
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>
|
||||
|
||||
|
|
|
|||
|
|
@ -65,8 +65,8 @@
|
|||
prefixIcon
|
||||
>
|
||||
<svg slot="prefix" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<circle cx="7" cy="7" r="5" stroke="currentColor" stroke-width="1.5"/>
|
||||
<path d="M11 11l3 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<circle cx="7" cy="7" r="5" stroke="currentColor" stroke-width="1.5" />
|
||||
<path d="M11 11l3 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||
</svg>
|
||||
</Input>
|
||||
|
||||
|
|
@ -79,11 +79,7 @@
|
|||
step={5}
|
||||
/>
|
||||
|
||||
<Input
|
||||
type="color"
|
||||
label="Color Input"
|
||||
bind:value={colorValue}
|
||||
/>
|
||||
<Input type="color" label="Color Input" bind:value={colorValue} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
|
@ -102,23 +98,11 @@
|
|||
<section>
|
||||
<h2>Input Sizes</h2>
|
||||
<div class="input-group">
|
||||
<Input
|
||||
buttonSize="small"
|
||||
label="Small Input"
|
||||
placeholder="Small size"
|
||||
/>
|
||||
<Input buttonSize="small" label="Small Input" placeholder="Small size" />
|
||||
|
||||
<Input
|
||||
buttonSize="medium"
|
||||
label="Medium Input"
|
||||
placeholder="Medium size (default)"
|
||||
/>
|
||||
<Input buttonSize="medium" label="Medium Input" placeholder="Medium size (default)" />
|
||||
|
||||
<Input
|
||||
buttonSize="large"
|
||||
label="Large Input"
|
||||
placeholder="Large size"
|
||||
/>
|
||||
<Input buttonSize="large" label="Large Input" placeholder="Large size" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
|
@ -129,46 +113,49 @@
|
|||
label="Input with Error"
|
||||
placeholder="Try typing something"
|
||||
bind:value={withErrorValue}
|
||||
error={withErrorValue.length > 0 && withErrorValue.length < 3 ? "Too short! Minimum 3 characters" : ""}
|
||||
error={withErrorValue.length > 0 && withErrorValue.length < 3
|
||||
? 'Too short! Minimum 3 characters'
|
||||
: ''}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Disabled Input"
|
||||
bind:value={disabledValue}
|
||||
disabled
|
||||
/>
|
||||
<Input label="Disabled Input" bind:value={disabledValue} disabled />
|
||||
|
||||
<Input
|
||||
label="Readonly Input"
|
||||
bind:value={readonlyValue}
|
||||
readonly
|
||||
/>
|
||||
<Input label="Readonly Input" bind:value={readonlyValue} readonly />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Input with Icons</h2>
|
||||
<div class="input-group">
|
||||
<Input
|
||||
label="With Prefix Icon"
|
||||
placeholder="Username"
|
||||
prefixIcon
|
||||
>
|
||||
<Input label="With Prefix Icon" placeholder="Username" prefixIcon>
|
||||
<svg slot="prefix" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<circle cx="8" cy="8" r="3" stroke="currentColor" stroke-width="1.5"/>
|
||||
<path d="M4 14c0-2.21 1.79-4 4-4s4 1.79 4 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<circle cx="8" cy="8" r="3" stroke="currentColor" stroke-width="1.5" />
|
||||
<path
|
||||
d="M4 14c0-2.21 1.79-4 4-4s4 1.79 4 4"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</Input>
|
||||
|
||||
<Input
|
||||
label="With Suffix Icon"
|
||||
placeholder="Email"
|
||||
type="email"
|
||||
suffixIcon
|
||||
>
|
||||
<Input label="With Suffix Icon" placeholder="Email" type="email" suffixIcon>
|
||||
<svg slot="suffix" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<rect x="2" y="4" width="12" height="8" rx="1" stroke="currentColor" stroke-width="1.5"/>
|
||||
<path d="M2 5l6 3 6-3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<rect
|
||||
x="2"
|
||||
y="4"
|
||||
width="12"
|
||||
height="8"
|
||||
rx="1"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M2 5l6 3 6-3"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</Input>
|
||||
</div>
|
||||
|
|
@ -198,11 +185,7 @@
|
|||
<section>
|
||||
<h2>Form Example</h2>
|
||||
<form class="demo-form" on:submit|preventDefault>
|
||||
<Input
|
||||
label="Project Name"
|
||||
placeholder="My Awesome Project"
|
||||
required
|
||||
/>
|
||||
<Input label="Project Name" placeholder="My Awesome Project" required />
|
||||
|
||||
<Input
|
||||
type="url"
|
||||
|
|
|
|||
|
|
@ -42,9 +42,7 @@
|
|||
<h2>Single Selection Mode</h2>
|
||||
<p>Test selecting a single media item.</p>
|
||||
|
||||
<Button variant="primary" onclick={openSingleModal}>
|
||||
Open Single Selection Modal
|
||||
</Button>
|
||||
<Button variant="primary" onclick={openSingleModal}>Open Single Selection Modal</Button>
|
||||
|
||||
{#if selectedSingleMedia}
|
||||
<div class="selected-media">
|
||||
|
|
@ -58,7 +56,10 @@
|
|||
<p><strong>Size:</strong> {formatFileSize(selectedSingleMedia.size)}</p>
|
||||
<p><strong>Type:</strong> {selectedSingleMedia.mimeType}</p>
|
||||
{#if selectedSingleMedia.width && selectedSingleMedia.height}
|
||||
<p><strong>Dimensions:</strong> {selectedSingleMedia.width}×{selectedSingleMedia.height}</p>
|
||||
<p>
|
||||
<strong>Dimensions:</strong>
|
||||
{selectedSingleMedia.width}×{selectedSingleMedia.height}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -70,9 +71,7 @@
|
|||
<h2>Multiple Selection Mode</h2>
|
||||
<p>Test selecting multiple media items.</p>
|
||||
|
||||
<Button variant="primary" onclick={openMultipleModal}>
|
||||
Open Multiple Selection Modal
|
||||
</Button>
|
||||
<Button variant="primary" onclick={openMultipleModal}>Open Multiple Selection Modal</Button>
|
||||
|
||||
{#if selectedMultipleMedia.length > 0}
|
||||
<div class="selected-media">
|
||||
|
|
@ -98,10 +97,13 @@
|
|||
<h2>Image Only Selection</h2>
|
||||
<p>Test selecting only image files.</p>
|
||||
|
||||
<Button variant="secondary" onclick={() => {
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -47,10 +47,13 @@
|
|||
|
||||
function addFiles(newFiles: File[]) {
|
||||
// Filter for image files
|
||||
const imageFiles = newFiles.filter(file => file.type.startsWith('image/'))
|
||||
const imageFiles = newFiles.filter((file) => file.type.startsWith('image/'))
|
||||
|
||||
if (imageFiles.length !== newFiles.length) {
|
||||
uploadErrors = [...uploadErrors, `${newFiles.length - imageFiles.length} non-image files were skipped`]
|
||||
uploadErrors = [
|
||||
...uploadErrors,
|
||||
`${newFiles.length - imageFiles.length} non-image files were skipped`
|
||||
]
|
||||
}
|
||||
|
||||
files = [...files, ...imageFiles]
|
||||
|
|
@ -98,7 +101,7 @@
|
|||
const response = await fetch('/api/media/upload', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Basic ${auth}`
|
||||
Authorization: `Basic ${auth}`
|
||||
},
|
||||
body: formData
|
||||
})
|
||||
|
|
@ -156,12 +159,54 @@
|
|||
<div class="drop-zone-content">
|
||||
{#if files.length === 0}
|
||||
<div class="upload-icon">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14 2H6C4.9 2 4 2.9 4 4V20C4 21.1 4.9 22 6 22H18C19.1 22 20 21.1 20 20V8L14 2Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<polyline points="14,2 14,8 20,8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<line x1="16" y1="13" x2="8" y2="13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<line x1="16" y1="17" x2="8" y2="17" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<polyline points="10,9 9,9 8,9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<svg
|
||||
width="48"
|
||||
height="48"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M14 2H6C4.9 2 4 2.9 4 4V20C4 21.1 4.9 22 6 22H18C19.1 22 20 21.1 20 20V8L14 2Z"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<polyline
|
||||
points="14,2 14,8 20,8"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<line
|
||||
x1="16"
|
||||
y1="13"
|
||||
x2="8"
|
||||
y2="13"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<line
|
||||
x1="16"
|
||||
y1="17"
|
||||
x2="8"
|
||||
y2="17"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<polyline
|
||||
points="10,9 9,9 8,9"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3>Drop images here</h3>
|
||||
|
|
@ -200,7 +245,12 @@
|
|||
<div class="file-list-header">
|
||||
<h3>Files to Upload</h3>
|
||||
<div class="file-actions">
|
||||
<Button variant="secondary" buttonSize="small" onclick={clearAll} disabled={isUploading}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
buttonSize="small"
|
||||
onclick={clearAll}
|
||||
disabled={isUploading}
|
||||
>
|
||||
Clear All
|
||||
</Button>
|
||||
<Button
|
||||
|
|
@ -248,7 +298,14 @@
|
|||
onclick={() => removeFile(index)}
|
||||
title="Remove file"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
|
|
@ -267,7 +324,7 @@
|
|||
<div class="success-message">
|
||||
✅ Successfully uploaded {successCount} file{successCount !== 1 ? 's' : ''}
|
||||
{#if successCount === files.length && uploadErrors.length === 0}
|
||||
<br><small>Redirecting to media library...</small>
|
||||
<br /><small>Redirecting to media library...</small>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -171,7 +171,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
function handleMetadataPopover(event: MouseEvent) {
|
||||
const target = event.target as Node
|
||||
// Don't close if clicking inside the metadata button or anywhere in a metadata popover
|
||||
|
|
@ -184,7 +183,6 @@
|
|||
showMetadata = false
|
||||
}
|
||||
|
||||
|
||||
$effect(() => {
|
||||
if (showMetadata) {
|
||||
document.addEventListener('click', handleMetadataPopover)
|
||||
|
|
@ -240,7 +238,7 @@
|
|||
onAddTag={addTag}
|
||||
onRemoveTag={removeTag}
|
||||
onDelete={openDeleteConfirmation}
|
||||
onClose={() => showMetadata = false}
|
||||
onClose={() => (showMetadata = false)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -373,7 +371,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
.btn {
|
||||
padding: $unit-2x $unit-3x;
|
||||
border: none;
|
||||
|
|
|
|||
|
|
@ -114,8 +114,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Mock post object for metadata popover
|
||||
const mockPost = $derived({
|
||||
id: null,
|
||||
|
|
@ -171,7 +169,7 @@
|
|||
onAddTag={addTag}
|
||||
onRemoveTag={removeTag}
|
||||
onDelete={() => {}}
|
||||
onClose={() => showMetadata = false}
|
||||
onClose={() => (showMetadata = false)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -252,7 +250,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
.btn {
|
||||
padding: $unit-2x $unit-3x;
|
||||
border: none;
|
||||
|
|
|
|||
|
|
@ -233,7 +233,8 @@
|
|||
{#if selectedStatusFilter === 'all' && selectedTypeFilter === 'all'}
|
||||
No projects found. Create your first project!
|
||||
{:else}
|
||||
No projects found matching the current filters. Try adjusting your filters or create a new project.
|
||||
No projects found matching the current filters. Try adjusting your filters or create a
|
||||
new project.
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -263,8 +264,6 @@
|
|||
/>
|
||||
|
||||
<style lang="scss">
|
||||
|
||||
|
||||
.error {
|
||||
text-align: center;
|
||||
padding: $unit-6x;
|
||||
|
|
|
|||
|
|
@ -189,7 +189,7 @@
|
|||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: $grey-10;
|
||||
}
|
||||
}
|
||||
|
||||
.back-link {
|
||||
color: $grey-40;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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,16 +46,16 @@ export const GET: RequestHandler = async (event) => {
|
|||
|
||||
// Create a map of media by mediaId for efficient lookup
|
||||
const mediaMap = new Map()
|
||||
mediaUsages.forEach(usage => {
|
||||
mediaUsages.forEach((usage) => {
|
||||
if (usage.media) {
|
||||
mediaMap.set(usage.mediaId, usage.media)
|
||||
}
|
||||
})
|
||||
|
||||
// Enrich photos with media information using proper media usage tracking
|
||||
const photosWithMedia = album.photos.map(photo => {
|
||||
const photosWithMedia = album.photos.map((photo) => {
|
||||
// Find the corresponding media usage record for this photo
|
||||
const usage = mediaUsages.find(u => u.media && u.media.filename === photo.filename)
|
||||
const usage = mediaUsages.find((u) => u.media && u.media.filename === photo.filename)
|
||||
const media = usage?.media
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
import type { RequestHandler } from './$types'
|
||||
import { prisma } from '$lib/server/database'
|
||||
import { jsonResponse, errorResponse, checkAdminAuth, parseRequestBody } from '$lib/server/api-utils'
|
||||
import {
|
||||
jsonResponse,
|
||||
errorResponse,
|
||||
checkAdminAuth,
|
||||
parseRequestBody
|
||||
} from '$lib/server/api-utils'
|
||||
import { logger } from '$lib/server/logger'
|
||||
|
||||
// POST /api/albums/[id]/photos - Add a photo to an album
|
||||
|
|
|
|||
|
|
@ -1,7 +1,12 @@
|
|||
import type { RequestHandler } from './$types'
|
||||
import { prisma } from '$lib/server/database'
|
||||
import { deleteFile, extractPublicId } from '$lib/server/cloudinary'
|
||||
import { jsonResponse, errorResponse, checkAdminAuth, parseRequestBody } from '$lib/server/api-utils'
|
||||
import {
|
||||
jsonResponse,
|
||||
errorResponse,
|
||||
checkAdminAuth,
|
||||
parseRequestBody
|
||||
} from '$lib/server/api-utils'
|
||||
import { logger } from '$lib/server/logger'
|
||||
|
||||
// GET /api/media/[id] - Get a single media item
|
||||
|
|
|
|||
|
|
@ -57,7 +57,6 @@ export const PATCH: RequestHandler = async (event) => {
|
|||
description: updatedMedia.description,
|
||||
updatedAt: updatedMedia.updatedAt
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Media metadata update error', error as Error)
|
||||
return errorResponse('Failed to update media metadata', 500)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,12 @@ import type { RequestHandler } from './$types'
|
|||
import { prisma } from '$lib/server/database'
|
||||
import { jsonResponse, errorResponse, checkAdminAuth } from '$lib/server/api-utils'
|
||||
import { logger } from '$lib/server/logger'
|
||||
import { trackMediaUsage, extractMediaIds, removeMediaUsage, type MediaUsageReference } from '$lib/server/media-usage.js'
|
||||
import {
|
||||
trackMediaUsage,
|
||||
extractMediaIds,
|
||||
removeMediaUsage,
|
||||
type MediaUsageReference
|
||||
} from '$lib/server/media-usage.js'
|
||||
|
||||
// POST /api/media/backfill-usage - Backfill media usage tracking for all content
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
|
|
@ -32,7 +37,7 @@ export const POST: RequestHandler = async (event) => {
|
|||
for (const project of projects) {
|
||||
// Track featured image
|
||||
const featuredImageIds = extractMediaIds(project, 'featuredImage')
|
||||
featuredImageIds.forEach(mediaId => {
|
||||
featuredImageIds.forEach((mediaId) => {
|
||||
usageReferences.push({
|
||||
mediaId,
|
||||
contentType: 'project',
|
||||
|
|
@ -43,7 +48,7 @@ export const POST: RequestHandler = async (event) => {
|
|||
|
||||
// Track logo
|
||||
const logoIds = extractMediaIds(project, 'logoUrl')
|
||||
logoIds.forEach(mediaId => {
|
||||
logoIds.forEach((mediaId) => {
|
||||
usageReferences.push({
|
||||
mediaId,
|
||||
contentType: 'project',
|
||||
|
|
@ -54,7 +59,7 @@ export const POST: RequestHandler = async (event) => {
|
|||
|
||||
// Track gallery images
|
||||
const galleryIds = extractMediaIds(project, 'gallery')
|
||||
galleryIds.forEach(mediaId => {
|
||||
galleryIds.forEach((mediaId) => {
|
||||
usageReferences.push({
|
||||
mediaId,
|
||||
contentType: 'project',
|
||||
|
|
@ -65,7 +70,7 @@ export const POST: RequestHandler = async (event) => {
|
|||
|
||||
// Track media in case study content
|
||||
const contentIds = extractMediaIds(project, 'caseStudyContent')
|
||||
contentIds.forEach(mediaId => {
|
||||
contentIds.forEach((mediaId) => {
|
||||
usageReferences.push({
|
||||
mediaId,
|
||||
contentType: 'project',
|
||||
|
|
@ -88,7 +93,7 @@ export const POST: RequestHandler = async (event) => {
|
|||
for (const post of posts) {
|
||||
// Track featured image
|
||||
const featuredImageIds = extractMediaIds(post, 'featuredImage')
|
||||
featuredImageIds.forEach(mediaId => {
|
||||
featuredImageIds.forEach((mediaId) => {
|
||||
usageReferences.push({
|
||||
mediaId,
|
||||
contentType: 'post',
|
||||
|
|
@ -99,7 +104,7 @@ export const POST: RequestHandler = async (event) => {
|
|||
|
||||
// Track attachments
|
||||
const attachmentIds = extractMediaIds(post, 'attachments')
|
||||
attachmentIds.forEach(mediaId => {
|
||||
attachmentIds.forEach((mediaId) => {
|
||||
usageReferences.push({
|
||||
mediaId,
|
||||
contentType: 'post',
|
||||
|
|
@ -110,7 +115,7 @@ export const POST: RequestHandler = async (event) => {
|
|||
|
||||
// Track media in post content
|
||||
const contentIds = extractMediaIds(post, 'content')
|
||||
contentIds.forEach(mediaId => {
|
||||
contentIds.forEach((mediaId) => {
|
||||
usageReferences.push({
|
||||
mediaId,
|
||||
contentType: 'post',
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
import type { RequestHandler } from './$types'
|
||||
import { prisma } from '$lib/server/database'
|
||||
import { jsonResponse, errorResponse, checkAdminAuth, parseRequestBody } from '$lib/server/api-utils'
|
||||
import {
|
||||
jsonResponse,
|
||||
errorResponse,
|
||||
checkAdminAuth,
|
||||
parseRequestBody
|
||||
} from '$lib/server/api-utils'
|
||||
import { logger } from '$lib/server/logger'
|
||||
import { removeMediaUsage, extractMediaIds } from '$lib/server/media-usage.js'
|
||||
|
||||
|
|
@ -17,7 +22,7 @@ export const DELETE: RequestHandler = async (event) => {
|
|||
return errorResponse('Invalid request body. Expected array of media IDs.', 400)
|
||||
}
|
||||
|
||||
const mediaIds = body.mediaIds.filter(id => typeof id === 'number' && !isNaN(id))
|
||||
const mediaIds = body.mediaIds.filter((id) => typeof id === 'number' && !isNaN(id))
|
||||
if (mediaIds.length === 0) {
|
||||
return errorResponse('No valid media IDs provided', 400)
|
||||
}
|
||||
|
|
@ -50,16 +55,15 @@ export const DELETE: RequestHandler = async (event) => {
|
|||
logger.info('Bulk media deletion completed', {
|
||||
deletedCount: deleteResult.count,
|
||||
mediaIds,
|
||||
filenames: mediaRecords.map(m => m.filename)
|
||||
filenames: mediaRecords.map((m) => m.filename)
|
||||
})
|
||||
|
||||
return jsonResponse({
|
||||
success: true,
|
||||
message: `Successfully deleted ${deleteResult.count} media file${deleteResult.count > 1 ? 's' : ''}`,
|
||||
deletedCount: deleteResult.count,
|
||||
deletedFiles: mediaRecords.map(m => ({ id: m.id, filename: m.filename }))
|
||||
deletedFiles: mediaRecords.map((m) => ({ id: m.id, filename: m.filename }))
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Failed to bulk delete media files', error as Error)
|
||||
return errorResponse('Failed to delete media files', 500)
|
||||
|
|
@ -74,7 +78,7 @@ async function cleanupMediaReferences(mediaIds: number[]) {
|
|||
where: { id: { in: mediaIds } },
|
||||
select: { url: true }
|
||||
})
|
||||
const urlsToRemove = mediaUrls.map(m => m.url)
|
||||
const urlsToRemove = mediaUrls.map((m) => m.url)
|
||||
|
||||
// Clean up projects
|
||||
const projects = await prisma.project.findMany({
|
||||
|
|
@ -195,7 +199,7 @@ function cleanContentFromMedia(content: any, mediaIds: number[], urlsToRemove: s
|
|||
|
||||
// Remove image nodes that reference deleted media
|
||||
if (node.type === 'image' && node.attrs?.src) {
|
||||
const shouldRemove = urlsToRemove.some(url => node.attrs.src.includes(url))
|
||||
const shouldRemove = urlsToRemove.some((url) => node.attrs.src.includes(url))
|
||||
if (shouldRemove) {
|
||||
return null // Mark for removal
|
||||
}
|
||||
|
|
@ -203,9 +207,7 @@ function cleanContentFromMedia(content: any, mediaIds: number[], urlsToRemove: s
|
|||
|
||||
// Clean gallery nodes
|
||||
if (node.type === 'gallery' && node.attrs?.images) {
|
||||
const filteredImages = node.attrs.images.filter((image: any) =>
|
||||
!mediaIds.includes(image.id)
|
||||
)
|
||||
const filteredImages = node.attrs.images.filter((image: any) => !mediaIds.includes(image.id))
|
||||
|
||||
if (filteredImages.length === 0) {
|
||||
return null // Remove empty gallery
|
||||
|
|
@ -222,9 +224,7 @@ function cleanContentFromMedia(content: any, mediaIds: number[], urlsToRemove: s
|
|||
|
||||
// Recursively clean child nodes
|
||||
if (node.content) {
|
||||
const cleanedContent = node.content
|
||||
.map(cleanNode)
|
||||
.filter((child: any) => child !== null)
|
||||
const cleanedContent = node.content.map(cleanNode).filter((child: any) => child !== null)
|
||||
|
||||
return {
|
||||
...node,
|
||||
|
|
|
|||
|
|
@ -12,9 +12,21 @@ async function extractExifData(file: File): Promise<any> {
|
|||
const buffer = await file.arrayBuffer()
|
||||
const exif = await exifr.parse(buffer, {
|
||||
pick: [
|
||||
'Make', 'Model', 'LensModel', 'FocalLength', 'FNumber', 'ExposureTime',
|
||||
'ISO', 'DateTime', 'DateTimeOriginal', 'CreateDate', 'GPSLatitude',
|
||||
'GPSLongitude', 'GPSAltitude', 'Orientation', 'ColorSpace'
|
||||
'Make',
|
||||
'Model',
|
||||
'LensModel',
|
||||
'FocalLength',
|
||||
'FNumber',
|
||||
'ExposureTime',
|
||||
'ISO',
|
||||
'DateTime',
|
||||
'DateTimeOriginal',
|
||||
'CreateDate',
|
||||
'GPSLatitude',
|
||||
'GPSLongitude',
|
||||
'GPSAltitude',
|
||||
'Orientation',
|
||||
'ColorSpace'
|
||||
]
|
||||
})
|
||||
|
||||
|
|
@ -77,7 +89,9 @@ async function extractExifData(file: File): Promise<any> {
|
|||
|
||||
return Object.keys(formattedExif).length > 0 ? formattedExif : null
|
||||
} catch (error) {
|
||||
logger.warn('Failed to extract EXIF data', { error: error instanceof Error ? error.message : 'Unknown error' })
|
||||
logger.warn('Failed to extract EXIF data', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
})
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
|
@ -183,7 +197,10 @@ export const POST: RequestHandler = async (event) => {
|
|||
} catch (error) {
|
||||
logger.error('Media upload error', error as Error)
|
||||
console.error('Detailed upload error:', error)
|
||||
return errorResponse(`Upload failed: ${error instanceof Error ? error.message : 'Unknown error'}`, 500)
|
||||
return errorResponse(
|
||||
`Upload failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
500
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -66,8 +66,8 @@ export const GET: RequestHandler = async (event) => {
|
|||
|
||||
// Transform albums to PhotoAlbum format
|
||||
const photoAlbums: PhotoAlbum[] = albums
|
||||
.filter(album => album.photos.length > 0) // Only include albums with published photos
|
||||
.map(album => ({
|
||||
.filter((album) => album.photos.length > 0) // Only include albums with published photos
|
||||
.map((album) => ({
|
||||
id: `album-${album.id}`,
|
||||
slug: album.slug, // Add slug for navigation
|
||||
title: album.title,
|
||||
|
|
@ -80,7 +80,7 @@ export const GET: RequestHandler = async (event) => {
|
|||
width: album.photos[0].width || 400,
|
||||
height: album.photos[0].height || 400
|
||||
},
|
||||
photos: album.photos.map(photo => ({
|
||||
photos: album.photos.map((photo) => ({
|
||||
id: `photo-${photo.id}`,
|
||||
src: photo.url,
|
||||
alt: photo.caption || photo.filename,
|
||||
|
|
@ -92,7 +92,7 @@ export const GET: RequestHandler = async (event) => {
|
|||
}))
|
||||
|
||||
// Transform individual photos to Photo format
|
||||
const photos: Photo[] = individualPhotos.map(photo => ({
|
||||
const photos: Photo[] = individualPhotos.map((photo) => ({
|
||||
id: `photo-${photo.id}`,
|
||||
src: photo.url,
|
||||
alt: photo.title || photo.caption || photo.filename,
|
||||
|
|
|
|||
|
|
@ -45,13 +45,13 @@ export const GET: RequestHandler = async (event) => {
|
|||
}
|
||||
|
||||
// Find the specific photo
|
||||
const photo = album.photos.find(p => p.id === photoId)
|
||||
const photo = album.photos.find((p) => p.id === photoId)
|
||||
if (!photo) {
|
||||
return errorResponse('Photo not found in album', 404)
|
||||
}
|
||||
|
||||
// Get photo index for navigation
|
||||
const photoIndex = album.photos.findIndex(p => p.id === photoId)
|
||||
const photoIndex = album.photos.findIndex((p) => p.id === photoId)
|
||||
const prevPhoto = photoIndex > 0 ? album.photos[photoIndex - 1] : null
|
||||
const nextPhoto = photoIndex < album.photos.length - 1 ? album.photos[photoIndex + 1] : null
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,11 @@ import {
|
|||
checkAdminAuth
|
||||
} from '$lib/server/api-utils'
|
||||
import { logger } from '$lib/server/logger'
|
||||
import { trackMediaUsage, extractMediaIds, type MediaUsageReference } from '$lib/server/media-usage.js'
|
||||
import {
|
||||
trackMediaUsage,
|
||||
extractMediaIds,
|
||||
type MediaUsageReference
|
||||
} from '$lib/server/media-usage.js'
|
||||
|
||||
// GET /api/posts - List all posts
|
||||
export const GET: RequestHandler = async (event) => {
|
||||
|
|
@ -126,7 +130,8 @@ export const POST: RequestHandler = async (event) => {
|
|||
linkUrl: data.link_url,
|
||||
linkDescription: data.linkDescription,
|
||||
featuredImage: featuredImageId,
|
||||
attachments: data.attachedPhotos && data.attachedPhotos.length > 0 ? data.attachedPhotos : null,
|
||||
attachments:
|
||||
data.attachedPhotos && data.attachedPhotos.length > 0 ? data.attachedPhotos : null,
|
||||
tags: data.tags,
|
||||
publishedAt: data.publishedAt
|
||||
}
|
||||
|
|
@ -138,7 +143,7 @@ export const POST: RequestHandler = async (event) => {
|
|||
|
||||
// Track featured image
|
||||
const featuredImageIds = extractMediaIds({ featuredImage: featuredImageId }, 'featuredImage')
|
||||
featuredImageIds.forEach(mediaId => {
|
||||
featuredImageIds.forEach((mediaId) => {
|
||||
usageReferences.push({
|
||||
mediaId,
|
||||
contentType: 'post',
|
||||
|
|
@ -173,7 +178,7 @@ export const POST: RequestHandler = async (event) => {
|
|||
|
||||
// Track media in post content
|
||||
const contentIds = extractMediaIds({ content: postContent }, 'content')
|
||||
contentIds.forEach(mediaId => {
|
||||
contentIds.forEach((mediaId) => {
|
||||
usageReferences.push({
|
||||
mediaId,
|
||||
contentType: 'post',
|
||||
|
|
|
|||
|
|
@ -2,7 +2,13 @@ import type { RequestHandler } from './$types'
|
|||
import { prisma } from '$lib/server/database'
|
||||
import { jsonResponse, errorResponse, checkAdminAuth } from '$lib/server/api-utils'
|
||||
import { logger } from '$lib/server/logger'
|
||||
import { updateMediaUsage, removeMediaUsage, extractMediaIds, trackMediaUsage, type MediaUsageReference } from '$lib/server/media-usage.js'
|
||||
import {
|
||||
updateMediaUsage,
|
||||
removeMediaUsage,
|
||||
extractMediaIds,
|
||||
trackMediaUsage,
|
||||
type MediaUsageReference
|
||||
} from '$lib/server/media-usage.js'
|
||||
|
||||
// GET /api/posts/[id] - Get a single post
|
||||
export const GET: RequestHandler = async (event) => {
|
||||
|
|
@ -93,7 +99,8 @@ export const PUT: RequestHandler = async (event) => {
|
|||
linkUrl: data.link_url,
|
||||
linkDescription: data.linkDescription,
|
||||
featuredImage: featuredImageId,
|
||||
attachments: data.attachedPhotos && data.attachedPhotos.length > 0 ? data.attachedPhotos : null,
|
||||
attachments:
|
||||
data.attachedPhotos && data.attachedPhotos.length > 0 ? data.attachedPhotos : null,
|
||||
tags: data.tags,
|
||||
publishedAt: data.publishedAt
|
||||
}
|
||||
|
|
@ -109,7 +116,7 @@ export const PUT: RequestHandler = async (event) => {
|
|||
|
||||
// Track featured image
|
||||
const featuredImageIds = extractMediaIds(post, 'featuredImage')
|
||||
featuredImageIds.forEach(mediaId => {
|
||||
featuredImageIds.forEach((mediaId) => {
|
||||
usageReferences.push({
|
||||
mediaId,
|
||||
contentType: 'post',
|
||||
|
|
@ -120,7 +127,7 @@ export const PUT: RequestHandler = async (event) => {
|
|||
|
||||
// Track attachments
|
||||
const attachmentIds = extractMediaIds(post, 'attachments')
|
||||
attachmentIds.forEach(mediaId => {
|
||||
attachmentIds.forEach((mediaId) => {
|
||||
usageReferences.push({
|
||||
mediaId,
|
||||
contentType: 'post',
|
||||
|
|
@ -131,7 +138,7 @@ export const PUT: RequestHandler = async (event) => {
|
|||
|
||||
// Track media in post content
|
||||
const contentIds = extractMediaIds(post, 'content')
|
||||
contentIds.forEach(mediaId => {
|
||||
contentIds.forEach((mediaId) => {
|
||||
usageReferences.push({
|
||||
mediaId,
|
||||
contentType: 'post',
|
||||
|
|
|
|||
|
|
@ -10,7 +10,11 @@ import {
|
|||
} from '$lib/server/api-utils'
|
||||
import { logger } from '$lib/server/logger'
|
||||
import { createSlug, ensureUniqueSlug } from '$lib/server/database'
|
||||
import { trackMediaUsage, extractMediaIds, type MediaUsageReference } from '$lib/server/media-usage.js'
|
||||
import {
|
||||
trackMediaUsage,
|
||||
extractMediaIds,
|
||||
type MediaUsageReference
|
||||
} from '$lib/server/media-usage.js'
|
||||
|
||||
// GET /api/projects - List all projects
|
||||
export const GET: RequestHandler = async (event) => {
|
||||
|
|
@ -22,7 +26,8 @@ export const GET: RequestHandler = async (event) => {
|
|||
const status = event.url.searchParams.get('status')
|
||||
const projectType = event.url.searchParams.get('projectType')
|
||||
const includeListOnly = event.url.searchParams.get('includeListOnly') === 'true'
|
||||
const includePasswordProtected = event.url.searchParams.get('includePasswordProtected') === 'true'
|
||||
const includePasswordProtected =
|
||||
event.url.searchParams.get('includePasswordProtected') === 'true'
|
||||
|
||||
// Build where clause
|
||||
const where: any = {}
|
||||
|
|
@ -126,7 +131,7 @@ export const POST: RequestHandler = async (event) => {
|
|||
|
||||
// Track featured image
|
||||
const featuredImageIds = extractMediaIds(body, 'featuredImage')
|
||||
featuredImageIds.forEach(mediaId => {
|
||||
featuredImageIds.forEach((mediaId) => {
|
||||
usageReferences.push({
|
||||
mediaId,
|
||||
contentType: 'project',
|
||||
|
|
@ -137,7 +142,7 @@ export const POST: RequestHandler = async (event) => {
|
|||
|
||||
// Track logo
|
||||
const logoIds = extractMediaIds(body, 'logoUrl')
|
||||
logoIds.forEach(mediaId => {
|
||||
logoIds.forEach((mediaId) => {
|
||||
usageReferences.push({
|
||||
mediaId,
|
||||
contentType: 'project',
|
||||
|
|
@ -148,7 +153,7 @@ export const POST: RequestHandler = async (event) => {
|
|||
|
||||
// Track gallery images
|
||||
const galleryIds = extractMediaIds(body, 'gallery')
|
||||
galleryIds.forEach(mediaId => {
|
||||
galleryIds.forEach((mediaId) => {
|
||||
usageReferences.push({
|
||||
mediaId,
|
||||
contentType: 'project',
|
||||
|
|
@ -159,7 +164,7 @@ export const POST: RequestHandler = async (event) => {
|
|||
|
||||
// Track media in case study content
|
||||
const contentIds = extractMediaIds(body, 'caseStudyContent')
|
||||
contentIds.forEach(mediaId => {
|
||||
contentIds.forEach((mediaId) => {
|
||||
usageReferences.push({
|
||||
mediaId,
|
||||
contentType: 'project',
|
||||
|
|
|
|||
|
|
@ -8,7 +8,12 @@ import {
|
|||
} from '$lib/server/api-utils'
|
||||
import { logger } from '$lib/server/logger'
|
||||
import { ensureUniqueSlug } from '$lib/server/database'
|
||||
import { updateMediaUsage, removeMediaUsage, extractMediaIds, type MediaUsageReference } from '$lib/server/media-usage.js'
|
||||
import {
|
||||
updateMediaUsage,
|
||||
removeMediaUsage,
|
||||
extractMediaIds,
|
||||
type MediaUsageReference
|
||||
} from '$lib/server/media-usage.js'
|
||||
|
||||
// GET /api/projects/[id] - Get a single project
|
||||
export const GET: RequestHandler = async (event) => {
|
||||
|
|
@ -103,7 +108,7 @@ export const PUT: RequestHandler = async (event) => {
|
|||
|
||||
// Track featured image
|
||||
const featuredImageIds = extractMediaIds(project, 'featuredImage')
|
||||
featuredImageIds.forEach(mediaId => {
|
||||
featuredImageIds.forEach((mediaId) => {
|
||||
usageReferences.push({
|
||||
mediaId,
|
||||
contentType: 'project',
|
||||
|
|
@ -114,7 +119,7 @@ export const PUT: RequestHandler = async (event) => {
|
|||
|
||||
// Track logo
|
||||
const logoIds = extractMediaIds(project, 'logoUrl')
|
||||
logoIds.forEach(mediaId => {
|
||||
logoIds.forEach((mediaId) => {
|
||||
usageReferences.push({
|
||||
mediaId,
|
||||
contentType: 'project',
|
||||
|
|
@ -125,7 +130,7 @@ export const PUT: RequestHandler = async (event) => {
|
|||
|
||||
// Track gallery images
|
||||
const galleryIds = extractMediaIds(project, 'gallery')
|
||||
galleryIds.forEach(mediaId => {
|
||||
galleryIds.forEach((mediaId) => {
|
||||
usageReferences.push({
|
||||
mediaId,
|
||||
contentType: 'project',
|
||||
|
|
@ -136,7 +141,7 @@ export const PUT: RequestHandler = async (event) => {
|
|||
|
||||
// Track media in case study content
|
||||
const contentIds = extractMediaIds(project, 'caseStudyContent')
|
||||
contentIds.forEach(mediaId => {
|
||||
contentIds.forEach((mediaId) => {
|
||||
usageReferences.push({
|
||||
mediaId,
|
||||
contentType: 'project',
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import 'dotenv/config'
|
||||
import { error, json } from '@sveltejs/kit'
|
||||
import redis from '../redis-client'
|
||||
import SteamAPI, { Game, GameInfo, GameInfoExtended, UserPlaytime } from 'steamapi'
|
||||
import SteamAPI from 'steamapi'
|
||||
|
||||
import type { RequestHandler } from './$types'
|
||||
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ export const GET: RequestHandler = async (event) => {
|
|||
})
|
||||
|
||||
// Transform posts to universe items
|
||||
const postItems: UniverseItem[] = posts.map(post => ({
|
||||
const postItems: UniverseItem[] = posts.map((post) => ({
|
||||
id: post.id,
|
||||
type: 'post' as const,
|
||||
slug: post.slug,
|
||||
|
|
@ -104,7 +104,7 @@ export const GET: RequestHandler = async (event) => {
|
|||
}))
|
||||
|
||||
// Transform albums to universe items
|
||||
const albumItems: UniverseItem[] = albums.map(album => ({
|
||||
const albumItems: UniverseItem[] = albums.map((album) => ({
|
||||
id: album.id,
|
||||
type: 'album' as const,
|
||||
slug: album.slug,
|
||||
|
|
@ -120,8 +120,9 @@ export const GET: RequestHandler = async (event) => {
|
|||
}))
|
||||
|
||||
// Combine and sort by publishedAt
|
||||
const allItems = [...postItems, ...albumItems]
|
||||
.sort((a, b) => new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime())
|
||||
const allItems = [...postItems, ...albumItems].sort(
|
||||
(a, b) => new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime()
|
||||
)
|
||||
|
||||
// Apply pagination
|
||||
const paginatedItems = allItems.slice(offset, offset + limit)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@ import type { PageLoad } from './$types'
|
|||
|
||||
export const load: PageLoad = async ({ fetch }) => {
|
||||
try {
|
||||
const response = await fetch('/api/projects?projectType=labs&includeListOnly=true&includePasswordProtected=true')
|
||||
const response = await fetch(
|
||||
'/api/projects?projectType=labs&includeListOnly=true&includePasswordProtected=true'
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch labs projects')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,11 +37,18 @@
|
|||
</Page>
|
||||
{:else if project.status === 'password-protected'}
|
||||
<Page>
|
||||
<ProjectPasswordProtection projectSlug={project.slug} correctPassword={project.password || ''} projectType="labs">
|
||||
<ProjectPasswordProtection
|
||||
projectSlug={project.slug}
|
||||
correctPassword={project.password || ''}
|
||||
projectType="labs"
|
||||
>
|
||||
{#snippet children()}
|
||||
<div slot="header" class="project-header">
|
||||
{#if project.logoUrl}
|
||||
<div class="project-logo" style="background-color: {project.backgroundColor || '#f5f5f5'}">
|
||||
<div
|
||||
class="project-logo"
|
||||
style="background-color: {project.backgroundColor || '#f5f5f5'}"
|
||||
>
|
||||
<img src={project.logoUrl} alt="{project.title} logo" />
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@ import type { Project } from '$lib/types/project'
|
|||
export const load: PageLoad = async ({ params, fetch }) => {
|
||||
try {
|
||||
// Find project by slug - we'll fetch all published, list-only, and password-protected projects
|
||||
const response = await fetch(`/api/projects?projectType=labs&includeListOnly=true&includePasswordProtected=true`)
|
||||
const response = await fetch(
|
||||
`/api/projects?projectType=labs&includeListOnly=true&includePasswordProtected=true`
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch projects')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,7 +34,9 @@
|
|||
exifData.aperture,
|
||||
formatSpeed(exifData.shutterSpeed),
|
||||
exifData.iso ? `ISO ${exifData.iso}` : null
|
||||
].filter(Boolean).join(' • '),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' • '),
|
||||
location: exifData.location,
|
||||
dateTaken: exifData.dateTaken
|
||||
}
|
||||
|
|
@ -45,12 +47,25 @@
|
|||
|
||||
<svelte:head>
|
||||
{#if photo && album}
|
||||
<title>{photo.title || photo.caption || `Photo ${navigation?.currentIndex}`} - {album.title}</title>
|
||||
<meta name="description" content={photo.description || photo.caption || `Photo from ${album.title}`} />
|
||||
<title
|
||||
>{photo.title || photo.caption || `Photo ${navigation?.currentIndex}`} - {album.title}</title
|
||||
>
|
||||
<meta
|
||||
name="description"
|
||||
content={photo.description || photo.caption || `Photo from ${album.title}`}
|
||||
/>
|
||||
|
||||
<!-- Open Graph meta tags -->
|
||||
<meta property="og:title" content="{photo.title || photo.caption || `Photo ${navigation?.currentIndex}`} - {album.title}" />
|
||||
<meta property="og:description" content={photo.description || photo.caption || `Photo from ${album.title}`} />
|
||||
<meta
|
||||
property="og:title"
|
||||
content="{photo.title ||
|
||||
photo.caption ||
|
||||
`Photo ${navigation?.currentIndex}`} - {album.title}"
|
||||
/>
|
||||
<meta
|
||||
property="og:description"
|
||||
content={photo.description || photo.caption || `Photo from ${album.title}`}
|
||||
/>
|
||||
<meta property="og:type" content="article" />
|
||||
<meta property="og:image" content={photo.url} />
|
||||
|
||||
|
|
@ -68,7 +83,7 @@
|
|||
<div class="error-container">
|
||||
<div class="error-content">
|
||||
<h1>Photo Not Found</h1>
|
||||
<p>{error || 'The photo you\'re looking for doesn\'t exist.'}</p>
|
||||
<p>{error || "The photo you're looking for doesn't exist."}</p>
|
||||
<a href="/photos" class="back-link">← Back to Photos</a>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -88,14 +103,26 @@
|
|||
{#if navigation.prevPhoto}
|
||||
<a href="/photos/{album.slug}/{navigation.prevPhoto.id}" class="nav-btn prev">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<path d="M12.5 15L7.5 10L12.5 5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path
|
||||
d="M12.5 15L7.5 10L12.5 5"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
Previous
|
||||
</a>
|
||||
{:else}
|
||||
<div class="nav-btn disabled">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<path d="M12.5 15L7.5 10L12.5 5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path
|
||||
d="M12.5 15L7.5 10L12.5 5"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
Previous
|
||||
</div>
|
||||
|
|
@ -105,14 +132,26 @@
|
|||
<a href="/photos/{album.slug}/{navigation.nextPhoto.id}" class="nav-btn next">
|
||||
Next
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<path d="M7.5 5L12.5 10L7.5 15" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path
|
||||
d="M7.5 5L12.5 10L7.5 15"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
{:else}
|
||||
<div class="nav-btn disabled">
|
||||
Next
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<path d="M7.5 5L12.5 10L7.5 15" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path
|
||||
d="M7.5 5L12.5 10L7.5 15"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -241,16 +280,16 @@
|
|||
min-height: 100vh;
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
"header header"
|
||||
"main details";
|
||||
'header header'
|
||||
'main details';
|
||||
grid-template-columns: 1fr 400px;
|
||||
grid-template-rows: auto 1fr;
|
||||
|
||||
@include breakpoint('tablet') {
|
||||
grid-template-areas:
|
||||
"header"
|
||||
"main"
|
||||
"details";
|
||||
'header'
|
||||
'main'
|
||||
'details';
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,14 +8,16 @@
|
|||
const error = $derived(data.error)
|
||||
|
||||
// Transform album data to PhotoItem format for PhotoGrid
|
||||
const photoItems = $derived(album?.photos?.map((photo: any) => ({
|
||||
id: `photo-${photo.id}`,
|
||||
src: photo.url,
|
||||
alt: photo.caption || photo.filename,
|
||||
caption: photo.caption,
|
||||
width: photo.width || 400,
|
||||
height: photo.height || 400
|
||||
})) ?? [])
|
||||
const photoItems = $derived(
|
||||
album?.photos?.map((photo: any) => ({
|
||||
id: `photo-${photo.id}`,
|
||||
src: photo.url,
|
||||
alt: photo.caption || photo.filename,
|
||||
caption: photo.caption,
|
||||
width: photo.width || 400,
|
||||
height: photo.height || 400
|
||||
})) ?? []
|
||||
)
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString)
|
||||
|
|
@ -68,13 +70,15 @@
|
|||
{#if album.location}
|
||||
<span class="meta-item">📍 {album.location}</span>
|
||||
{/if}
|
||||
<span class="meta-item">📷 {album.photos?.length || 0} photo{(album.photos?.length || 0) !== 1 ? 's' : ''}</span>
|
||||
<span class="meta-item"
|
||||
>📷 {album.photos?.length || 0} photo{(album.photos?.length || 0) !== 1 ? 's' : ''}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Photo Grid -->
|
||||
{#if photoItems.length > 0}
|
||||
<PhotoGrid photoItems={photoItems} albumSlug={album.slug} />
|
||||
<PhotoGrid {photoItems} albumSlug={album.slug} />
|
||||
{:else}
|
||||
<div class="empty-album">
|
||||
<p>This album doesn't contain any photos yet.</p>
|
||||
|
|
|
|||
|
|
@ -26,7 +26,9 @@ function convertContentToHTML(content: any): string {
|
|||
const level = block.level || 2
|
||||
return `<h${level}>${escapeXML(block.content || '')}</h${level}>`
|
||||
case 'list':
|
||||
const items = (block.content || []).map((item: any) => `<li>${escapeXML(item)}</li>`).join('')
|
||||
const items = (block.content || [])
|
||||
.map((item: any) => `<li>${escapeXML(item)}</li>`)
|
||||
.join('')
|
||||
return block.listType === 'ordered' ? `<ol>${items}</ol>` : `<ul>${items}</ul>`
|
||||
default:
|
||||
return `<p>${escapeXML(block.content || '')}</p>`
|
||||
|
|
@ -119,11 +121,12 @@ export const GET: RequestHandler = async (event) => {
|
|||
|
||||
// Combine all content types
|
||||
const items = [
|
||||
...posts.map(post => ({
|
||||
...posts.map((post) => ({
|
||||
type: 'post',
|
||||
section: 'universe',
|
||||
id: post.id.toString(),
|
||||
title: post.title || `${post.postType.charAt(0).toUpperCase() + post.postType.slice(1)} Post`,
|
||||
title:
|
||||
post.title || `${post.postType.charAt(0).toUpperCase() + post.postType.slice(1)} Post`,
|
||||
description: post.excerpt || extractTextSummary(post.content) || '',
|
||||
content: convertContentToHTML(post.content),
|
||||
link: `${event.url.origin}/universe/${post.slug}`,
|
||||
|
|
@ -133,12 +136,14 @@ export const GET: RequestHandler = async (event) => {
|
|||
postType: post.postType,
|
||||
linkUrl: post.linkUrl || null
|
||||
})),
|
||||
...universeAlbums.map(album => ({
|
||||
...universeAlbums.map((album) => ({
|
||||
type: 'album',
|
||||
section: 'universe',
|
||||
id: album.id.toString(),
|
||||
title: album.title,
|
||||
description: album.description || `Photo album with ${album._count.photos} photo${album._count.photos !== 1 ? 's' : ''}`,
|
||||
description:
|
||||
album.description ||
|
||||
`Photo album with ${album._count.photos} photo${album._count.photos !== 1 ? 's' : ''}`,
|
||||
content: album.description ? `<p>${escapeXML(album.description)}</p>` : '',
|
||||
link: `${event.url.origin}/photos/${album.slug}`,
|
||||
guid: `${event.url.origin}/photos/${album.slug}`,
|
||||
|
|
@ -149,13 +154,15 @@ export const GET: RequestHandler = async (event) => {
|
|||
location: album.location
|
||||
})),
|
||||
...photoAlbums
|
||||
.filter(album => !universeAlbums.some(ua => ua.id === album.id)) // Avoid duplicates
|
||||
.map(album => ({
|
||||
.filter((album) => !universeAlbums.some((ua) => ua.id === album.id)) // Avoid duplicates
|
||||
.map((album) => ({
|
||||
type: 'album',
|
||||
section: 'photos',
|
||||
id: album.id.toString(),
|
||||
title: album.title,
|
||||
description: album.description || `Photography album${album.location ? ` from ${album.location}` : ''} with ${album._count.photos} photo${album._count.photos !== 1 ? 's' : ''}`,
|
||||
description:
|
||||
album.description ||
|
||||
`Photography album${album.location ? ` from ${album.location}` : ''} with ${album._count.photos} photo${album._count.photos !== 1 ? 's' : ''}`,
|
||||
content: album.description ? `<p>${escapeXML(album.description)}</p>` : '',
|
||||
link: `${event.url.origin}/photos/${album.slug}`,
|
||||
guid: `${event.url.origin}/photos/${album.slug}`,
|
||||
|
|
@ -186,7 +193,9 @@ export const GET: RequestHandler = async (event) => {
|
|||
<generator>SvelteKit RSS Generator</generator>
|
||||
<docs>https://cyber.harvard.edu/rss/rss.html</docs>
|
||||
<ttl>60</ttl>
|
||||
${items.map(item => `
|
||||
${items
|
||||
.map(
|
||||
(item) => `
|
||||
<item>
|
||||
<title>${escapeXML(item.title)}</title>
|
||||
<description><![CDATA[${item.description}]]></description>
|
||||
|
|
@ -198,13 +207,19 @@ ${item.updatedDate ? `<atom:updated>${new Date(item.updatedDate).toISOString()}<
|
|||
<category>${item.section}</category>
|
||||
<category>${item.type === 'post' ? item.postType : 'album'}</category>
|
||||
${item.type === 'post' && item.linkUrl ? `<comments>${item.linkUrl}</comments>` : ''}
|
||||
${item.type === 'album' && item.coverPhoto ? `
|
||||
${
|
||||
item.type === 'album' && item.coverPhoto
|
||||
? `
|
||||
<enclosure url="${event.url.origin}${item.coverPhoto.url}" type="image/jpeg" length="0"/>
|
||||
<media:thumbnail url="${event.url.origin}${item.coverPhoto.thumbnailUrl || item.coverPhoto.url}"/>
|
||||
<media:content url="${event.url.origin}${item.coverPhoto.url}" type="image/jpeg"/>` : ''}
|
||||
<media:content url="${event.url.origin}${item.coverPhoto.url}" type="image/jpeg"/>`
|
||||
: ''
|
||||
}
|
||||
${item.location ? `<category domain="location">${escapeXML(item.location)}</category>` : ''}
|
||||
<author>noreply@jedmund.com (Justin Edmund)</author>
|
||||
</item>`).join('')}
|
||||
</item>`
|
||||
)
|
||||
.join('')}
|
||||
</channel>
|
||||
</rss>`
|
||||
|
||||
|
|
@ -215,9 +230,9 @@ ${item.location ? `<category domain="location">${escapeXML(item.location)}</cate
|
|||
'Content-Type': 'application/rss+xml; charset=utf-8',
|
||||
'Cache-Control': 'public, max-age=3600, stale-while-revalidate=86400',
|
||||
'Last-Modified': lastBuildDate,
|
||||
'ETag': `"${Buffer.from(rssXml).toString('base64').slice(0, 16)}"`,
|
||||
ETag: `"${Buffer.from(rssXml).toString('base64').slice(0, 16)}"`,
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'Vary': 'Accept-Encoding'
|
||||
Vary: 'Accept-Encoding'
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -63,30 +63,36 @@ export const GET: RequestHandler = async (event) => {
|
|||
|
||||
// Combine albums and standalone photos
|
||||
const items = [
|
||||
...albums.map(album => ({
|
||||
...albums.map((album) => ({
|
||||
type: 'album',
|
||||
id: album.id.toString(),
|
||||
title: album.title,
|
||||
description: album.description || `Photography album${album.location ? ` from ${album.location}` : ''} with ${album._count.photos} photo${album._count.photos !== 1 ? 's' : ''}`,
|
||||
content: album.description ? `<p>${escapeXML(album.description)}</p>` : '',
|
||||
description:
|
||||
album.description ||
|
||||
`Photography album${album.location ? ` from ${album.location}` : ''} with ${album._count.photos} photo${album._count.photos !== 1 ? 's' : ''}`,
|
||||
content: album.description ? `<p>${escapeXML(album.description)}</p>` : '',
|
||||
link: `${event.url.origin}/photos/${album.slug}`,
|
||||
pubDate: album.createdAt,
|
||||
updatedDate: album.updatedAt,
|
||||
updatedDate: album.updatedAt,
|
||||
guid: `${event.url.origin}/photos/${album.slug}`,
|
||||
photoCount: album._count.photos,
|
||||
coverPhoto: album.photos[0],
|
||||
location: album.location,
|
||||
date: album.date
|
||||
})),
|
||||
...standalonePhotos.map(photo => ({
|
||||
...standalonePhotos.map((photo) => ({
|
||||
type: 'photo',
|
||||
id: photo.id.toString(),
|
||||
title: photo.title || photo.filename,
|
||||
description: photo.description || photo.caption || `Photo: ${photo.filename}`,
|
||||
content: photo.description ? `<p>${escapeXML(photo.description)}</p>` : (photo.caption ? `<p>${escapeXML(photo.caption)}</p>` : ''),
|
||||
content: photo.description
|
||||
? `<p>${escapeXML(photo.description)}</p>`
|
||||
: photo.caption
|
||||
? `<p>${escapeXML(photo.caption)}</p>`
|
||||
: '',
|
||||
link: `${event.url.origin}/photos/photo/${photo.slug || photo.id}`,
|
||||
pubDate: photo.publishedAt || photo.createdAt,
|
||||
updatedDate: photo.updatedAt,
|
||||
updatedDate: photo.updatedAt,
|
||||
guid: `${event.url.origin}/photos/photo/${photo.slug || photo.id}`,
|
||||
url: photo.url,
|
||||
thumbnailUrl: photo.thumbnailUrl
|
||||
|
|
@ -111,7 +117,9 @@ export const GET: RequestHandler = async (event) => {
|
|||
<generator>SvelteKit RSS Generator</generator>
|
||||
<docs>https://cyber.harvard.edu/rss/rss.html</docs>
|
||||
<ttl>60</ttl>
|
||||
${items.map(item => `
|
||||
${items
|
||||
.map(
|
||||
(item) => `
|
||||
<item>
|
||||
<title>${escapeXML(item.title)}</title>
|
||||
<description><![CDATA[${item.description}]]></description>
|
||||
|
|
@ -121,17 +129,27 @@ ${item.content ? `<content:encoded><![CDATA[${item.content}]]></content:encoded>
|
|||
<pubDate>${formatRFC822Date(new Date(item.pubDate))}</pubDate>
|
||||
${item.updatedDate ? `<atom:updated>${new Date(item.updatedDate).toISOString()}</atom:updated>` : ''}
|
||||
<category>${item.type}</category>
|
||||
${item.type === 'album' && item.coverPhoto ? `
|
||||
${
|
||||
item.type === 'album' && item.coverPhoto
|
||||
? `
|
||||
<enclosure url="${event.url.origin}${item.coverPhoto.url}" type="image/jpeg" length="0"/>
|
||||
<media:thumbnail url="${event.url.origin}${item.coverPhoto.thumbnailUrl || item.coverPhoto.url}"/>
|
||||
<media:content url="${event.url.origin}${item.coverPhoto.url}" type="image/jpeg"/>` : ''}
|
||||
${item.type === 'photo' ? `
|
||||
<media:content url="${event.url.origin}${item.coverPhoto.url}" type="image/jpeg"/>`
|
||||
: ''
|
||||
}
|
||||
${
|
||||
item.type === 'photo'
|
||||
? `
|
||||
<enclosure url="${event.url.origin}${item.url}" type="image/jpeg" length="0"/>
|
||||
<media:thumbnail url="${event.url.origin}${item.thumbnailUrl || item.url}"/>
|
||||
<media:content url="${event.url.origin}${item.url}" type="image/jpeg"/>` : ''}
|
||||
<media:content url="${event.url.origin}${item.url}" type="image/jpeg"/>`
|
||||
: ''
|
||||
}
|
||||
${item.location ? `<category domain="location">${escapeXML(item.location)}</category>` : ''}
|
||||
<author>noreply@jedmund.com (Justin Edmund)</author>
|
||||
</item>`).join('')}
|
||||
</item>`
|
||||
)
|
||||
.join('')}
|
||||
</channel>
|
||||
</rss>`
|
||||
|
||||
|
|
@ -142,9 +160,9 @@ ${item.location ? `<category domain="location">${escapeXML(item.location)}</cate
|
|||
'Content-Type': 'application/rss+xml; charset=utf-8',
|
||||
'Cache-Control': 'public, max-age=3600, stale-while-revalidate=86400',
|
||||
'Last-Modified': lastBuildDate,
|
||||
'ETag': `"${Buffer.from(rssXml).toString('base64').slice(0, 16)}"`,
|
||||
ETag: `"${Buffer.from(rssXml).toString('base64').slice(0, 16)}"`,
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'Vary': 'Accept-Encoding'
|
||||
Vary: 'Accept-Encoding'
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -26,7 +26,9 @@ function convertContentToHTML(content: any): string {
|
|||
const level = block.level || 2
|
||||
return `<h${level}>${escapeXML(block.content || '')}</h${level}>`
|
||||
case 'list':
|
||||
const items = (block.content || []).map((item: any) => `<li>${escapeXML(item)}</li>`).join('')
|
||||
const items = (block.content || [])
|
||||
.map((item: any) => `<li>${escapeXML(item)}</li>`)
|
||||
.join('')
|
||||
return block.listType === 'ordered' ? `<ol>${items}</ol>` : `<ul>${items}</ul>`
|
||||
default:
|
||||
return `<p>${escapeXML(block.content || '')}</p>`
|
||||
|
|
@ -81,10 +83,11 @@ export const GET: RequestHandler = async (event) => {
|
|||
|
||||
// Combine and sort by date
|
||||
const items = [
|
||||
...posts.map(post => ({
|
||||
...posts.map((post) => ({
|
||||
type: 'post',
|
||||
id: post.id.toString(),
|
||||
title: post.title || `${post.postType.charAt(0).toUpperCase() + post.postType.slice(1)} Post`,
|
||||
title:
|
||||
post.title || `${post.postType.charAt(0).toUpperCase() + post.postType.slice(1)} Post`,
|
||||
description: post.excerpt || extractTextSummary(post.content) || '',
|
||||
content: convertContentToHTML(post.content),
|
||||
link: `${event.url.origin}/universe/${post.slug}`,
|
||||
|
|
@ -94,11 +97,13 @@ export const GET: RequestHandler = async (event) => {
|
|||
postType: post.postType,
|
||||
linkUrl: post.linkUrl || null
|
||||
})),
|
||||
...albums.map(album => ({
|
||||
...albums.map((album) => ({
|
||||
type: 'album',
|
||||
id: album.id.toString(),
|
||||
title: album.title,
|
||||
description: album.description || `Photo album with ${album._count.photos} photo${album._count.photos !== 1 ? 's' : ''}`,
|
||||
description:
|
||||
album.description ||
|
||||
`Photo album with ${album._count.photos} photo${album._count.photos !== 1 ? 's' : ''}`,
|
||||
content: album.description ? `<p>${escapeXML(album.description)}</p>` : '',
|
||||
link: `${event.url.origin}/photos/${album.slug}`,
|
||||
guid: `${event.url.origin}/photos/${album.slug}`,
|
||||
|
|
@ -126,7 +131,9 @@ export const GET: RequestHandler = async (event) => {
|
|||
<generator>SvelteKit RSS Generator</generator>
|
||||
<docs>https://cyber.harvard.edu/rss/rss.html</docs>
|
||||
<ttl>60</ttl>
|
||||
${items.map(item => `
|
||||
${items
|
||||
.map(
|
||||
(item) => `
|
||||
<item>
|
||||
<title>${escapeXML(item.title)}</title>
|
||||
<description><![CDATA[${item.description}]]></description>
|
||||
|
|
@ -138,7 +145,9 @@ ${item.updatedDate ? `<atom:updated>${new Date(item.updatedDate).toISOString()}<
|
|||
<category>${item.type === 'post' ? item.postType : 'album'}</category>
|
||||
${item.type === 'post' && item.linkUrl ? `<comments>${item.linkUrl}</comments>` : ''}
|
||||
<author>noreply@jedmund.com (Justin Edmund)</author>
|
||||
</item>`).join('')}
|
||||
</item>`
|
||||
)
|
||||
.join('')}
|
||||
</channel>
|
||||
</rss>`
|
||||
|
||||
|
|
@ -149,9 +158,9 @@ ${item.type === 'post' && item.linkUrl ? `<comments>${item.linkUrl}</comments>`
|
|||
'Content-Type': 'application/rss+xml; charset=utf-8',
|
||||
'Cache-Control': 'public, max-age=3600, stale-while-revalidate=86400',
|
||||
'Last-Modified': lastBuildDate,
|
||||
'ETag': `"${Buffer.from(rssXml).toString('base64').slice(0, 16)}"`,
|
||||
ETag: `"${Buffer.from(rssXml).toString('base64').slice(0, 16)}"`,
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'Vary': 'Accept-Encoding'
|
||||
Vary: 'Accept-Encoding'
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -13,11 +13,17 @@
|
|||
<svelte:head>
|
||||
{#if post}
|
||||
<title>{pageTitle} - jedmund</title>
|
||||
<meta name="description" content={post.excerpt || `${post.postType === 'essay' ? 'Essay' : 'Post'} by jedmund`} />
|
||||
<meta
|
||||
name="description"
|
||||
content={post.excerpt || `${post.postType === 'essay' ? 'Essay' : 'Post'} by jedmund`}
|
||||
/>
|
||||
|
||||
<!-- Open Graph meta tags -->
|
||||
<meta property="og:title" content={pageTitle} />
|
||||
<meta property="og:description" content={post.excerpt || `${post.postType === 'essay' ? 'Essay' : 'Post'} by jedmund`} />
|
||||
<meta
|
||||
property="og:description"
|
||||
content={post.excerpt || `${post.postType === 'essay' ? 'Essay' : 'Post'} by jedmund`}
|
||||
/>
|
||||
<meta property="og:type" content="article" />
|
||||
{#if post.attachments && post.attachments.length > 0}
|
||||
<meta property="og:image" content={post.attachments[0].url} />
|
||||
|
|
@ -36,7 +42,7 @@
|
|||
<div class="error-container">
|
||||
<div class="error-content">
|
||||
<h1>Post Not Found</h1>
|
||||
<p>{error || 'The post you\'re looking for doesn\'t exist.'}</p>
|
||||
<p>{error || "The post you're looking for doesn't exist."}</p>
|
||||
<a href="/universe" class="back-link">← Back to Universe</a>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -37,11 +37,18 @@
|
|||
</Page>
|
||||
{:else if project.status === 'password-protected'}
|
||||
<Page>
|
||||
<ProjectPasswordProtection projectSlug={project.slug} correctPassword={project.password || ''} projectType="work">
|
||||
<ProjectPasswordProtection
|
||||
projectSlug={project.slug}
|
||||
correctPassword={project.password || ''}
|
||||
projectType="work"
|
||||
>
|
||||
{#snippet children()}
|
||||
<div slot="header" class="project-header">
|
||||
{#if project.logoUrl}
|
||||
<div class="project-logo" style="background-color: {project.backgroundColor || '#f5f5f5'}">
|
||||
<div
|
||||
class="project-logo"
|
||||
style="background-color: {project.backgroundColor || '#f5f5f5'}"
|
||||
>
|
||||
<img src={project.logoUrl} alt="{project.title} logo" />
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -1,24 +1,24 @@
|
|||
<script module>
|
||||
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||
import Button from './Button.svelte';
|
||||
import { fn } from 'storybook/test';
|
||||
import { defineMeta } from '@storybook/addon-svelte-csf'
|
||||
import Button from './Button.svelte'
|
||||
import { fn } from 'storybook/test'
|
||||
|
||||
// More on how to set up stories at: https://storybook.js.org/docs/writing-stories
|
||||
const { Story } = defineMeta({
|
||||
title: 'Example/Button',
|
||||
component: Button,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
backgroundColor: { control: 'color' },
|
||||
size: {
|
||||
control: { type: 'select' },
|
||||
options: ['small', 'medium', 'large'],
|
||||
},
|
||||
},
|
||||
args: {
|
||||
onclick: fn(),
|
||||
}
|
||||
});
|
||||
// More on how to set up stories at: https://storybook.js.org/docs/writing-stories
|
||||
const { Story } = defineMeta({
|
||||
title: 'Example/Button',
|
||||
component: Button,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
backgroundColor: { control: 'color' },
|
||||
size: {
|
||||
control: { type: 'select' },
|
||||
options: ['small', 'medium', 'large']
|
||||
}
|
||||
},
|
||||
args: {
|
||||
onclick: fn()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<!-- More on writing stories with args: https://storybook.js.org/docs/writing-stories/args -->
|
||||
|
|
|
|||
|
|
@ -1,30 +1,30 @@
|
|||
<script lang="ts">
|
||||
import './button.css';
|
||||
import './button.css'
|
||||
|
||||
interface Props {
|
||||
/** Is this the principal call to action on the page? */
|
||||
primary?: boolean;
|
||||
/** What background color to use */
|
||||
backgroundColor?: string;
|
||||
/** How large should the button be? */
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
/** Button contents */
|
||||
label: string;
|
||||
/** The onclick event handler */
|
||||
onclick?: () => void;
|
||||
}
|
||||
interface Props {
|
||||
/** Is this the principal call to action on the page? */
|
||||
primary?: boolean
|
||||
/** What background color to use */
|
||||
backgroundColor?: string
|
||||
/** How large should the button be? */
|
||||
size?: 'small' | 'medium' | 'large'
|
||||
/** Button contents */
|
||||
label: string
|
||||
/** The onclick event handler */
|
||||
onclick?: () => void
|
||||
}
|
||||
|
||||
const { primary = false, backgroundColor, size = 'medium', label, ...props }: Props = $props();
|
||||
const { primary = false, backgroundColor, size = 'medium', label, ...props }: Props = $props()
|
||||
|
||||
let mode = $derived(primary ? 'storybook-button--primary' : 'storybook-button--secondary');
|
||||
let style = $derived(backgroundColor ? `background-color: ${backgroundColor}` : '');
|
||||
let mode = $derived(primary ? 'storybook-button--primary' : 'storybook-button--secondary')
|
||||
let style = $derived(backgroundColor ? `background-color: ${backgroundColor}` : '')
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class={['storybook-button', `storybook-button--${size}`, mode].join(' ')}
|
||||
{style}
|
||||
{...props}
|
||||
type="button"
|
||||
class={['storybook-button', `storybook-button--${size}`, mode].join(' ')}
|
||||
{style}
|
||||
{...props}
|
||||
>
|
||||
{label}
|
||||
{label}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -1,35 +1,37 @@
|
|||
import { Meta } from "@storybook/addon-docs/blocks";
|
||||
import { Meta } from '@storybook/addon-docs/blocks'
|
||||
|
||||
import Github from "./assets/github.svg";
|
||||
import Discord from "./assets/discord.svg";
|
||||
import Youtube from "./assets/youtube.svg";
|
||||
import Tutorials from "./assets/tutorials.svg";
|
||||
import Styling from "./assets/styling.png";
|
||||
import Context from "./assets/context.png";
|
||||
import Assets from "./assets/assets.png";
|
||||
import Docs from "./assets/docs.png";
|
||||
import Share from "./assets/share.png";
|
||||
import FigmaPlugin from "./assets/figma-plugin.png";
|
||||
import Testing from "./assets/testing.png";
|
||||
import Accessibility from "./assets/accessibility.png";
|
||||
import Theming from "./assets/theming.png";
|
||||
import AddonLibrary from "./assets/addon-library.png";
|
||||
import Github from './assets/github.svg'
|
||||
import Discord from './assets/discord.svg'
|
||||
import Youtube from './assets/youtube.svg'
|
||||
import Tutorials from './assets/tutorials.svg'
|
||||
import Styling from './assets/styling.png'
|
||||
import Context from './assets/context.png'
|
||||
import Assets from './assets/assets.png'
|
||||
import Docs from './assets/docs.png'
|
||||
import Share from './assets/share.png'
|
||||
import FigmaPlugin from './assets/figma-plugin.png'
|
||||
import Testing from './assets/testing.png'
|
||||
import Accessibility from './assets/accessibility.png'
|
||||
import Theming from './assets/theming.png'
|
||||
import AddonLibrary from './assets/addon-library.png'
|
||||
|
||||
export const RightArrow = () => <svg
|
||||
viewBox="0 0 14 14"
|
||||
width="8px"
|
||||
height="14px"
|
||||
style={{
|
||||
marginLeft: '4px',
|
||||
display: 'inline-block',
|
||||
shapeRendering: 'inherit',
|
||||
verticalAlign: 'middle',
|
||||
fill: 'currentColor',
|
||||
'path fill': 'currentColor'
|
||||
}}
|
||||
>
|
||||
<path d="m11.1 7.35-5.5 5.5a.5.5 0 0 1-.7-.7L10.04 7 4.9 1.85a.5.5 0 1 1 .7-.7l5.5 5.5c.2.2.2.5 0 .7Z" />
|
||||
</svg>
|
||||
export const RightArrow = () => (
|
||||
<svg
|
||||
viewBox="0 0 14 14"
|
||||
width="8px"
|
||||
height="14px"
|
||||
style={{
|
||||
marginLeft: '4px',
|
||||
display: 'inline-block',
|
||||
shapeRendering: 'inherit',
|
||||
verticalAlign: 'middle',
|
||||
fill: 'currentColor',
|
||||
'path fill': 'currentColor'
|
||||
}}
|
||||
>
|
||||
<path d="m11.1 7.35-5.5 5.5a.5.5 0 0 1-.7-.7L10.04 7 4.9 1.85a.5.5 0 1 1 .7-.7l5.5 5.5c.2.2.2.5 0 .7Z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
<Meta title="Configure your project" />
|
||||
|
||||
|
|
@ -38,6 +40,7 @@ export const RightArrow = () => <svg
|
|||
# Configure your project
|
||||
|
||||
Because Storybook works separately from your app, you'll need to configure it for your specific stack and setup. Below, explore guides for configuring Storybook with popular frameworks and tools. If you get stuck, learn how you can ask for help from our community.
|
||||
|
||||
</div>
|
||||
<div className="sb-section">
|
||||
<div className="sb-section-item">
|
||||
|
|
@ -84,6 +87,7 @@ export const RightArrow = () => <svg
|
|||
# Do more with Storybook
|
||||
|
||||
Now that you know the basics, let's explore other parts of Storybook that will improve your experience. This list is just to get you started. You can customise Storybook in many ways to fit your needs.
|
||||
|
||||
</div>
|
||||
|
||||
<div className="sb-section">
|
||||
|
|
@ -203,10 +207,11 @@ export const RightArrow = () => <svg
|
|||
target="_blank"
|
||||
>Discover tutorials<RightArrow /></a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<style>
|
||||
{`
|
||||
{`
|
||||
.sb-container {
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,24 +1,24 @@
|
|||
<script module>
|
||||
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||
import Header from './Header.svelte';
|
||||
import { fn } from 'storybook/test';
|
||||
import { defineMeta } from '@storybook/addon-svelte-csf'
|
||||
import Header from './Header.svelte'
|
||||
import { fn } from 'storybook/test'
|
||||
|
||||
// More on how to set up stories at: https://storybook.js.org/docs/writing-stories
|
||||
const { Story } = defineMeta({
|
||||
title: 'Example/Header',
|
||||
component: Header,
|
||||
// This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
// More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
|
||||
layout: 'fullscreen',
|
||||
},
|
||||
args: {
|
||||
onLogin: fn(),
|
||||
onLogout: fn(),
|
||||
onCreateAccount: fn(),
|
||||
}
|
||||
});
|
||||
// More on how to set up stories at: https://storybook.js.org/docs/writing-stories
|
||||
const { Story } = defineMeta({
|
||||
title: 'Example/Header',
|
||||
component: Header,
|
||||
// This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
// More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
|
||||
layout: 'fullscreen'
|
||||
},
|
||||
args: {
|
||||
onLogin: fn(),
|
||||
onLogout: fn(),
|
||||
onCreateAccount: fn()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<Story name="Logged In" args={{ user: { name: 'Jane Doe' } }} />
|
||||
|
|
|
|||
|
|
@ -1,45 +1,45 @@
|
|||
<script lang="ts">
|
||||
import './header.css';
|
||||
import Button from './Button.svelte';
|
||||
import './header.css'
|
||||
import Button from './Button.svelte'
|
||||
|
||||
interface Props {
|
||||
user?: { name: string };
|
||||
onLogin?: () => void;
|
||||
onLogout?: () => void;
|
||||
onCreateAccount?: () => void;
|
||||
}
|
||||
interface Props {
|
||||
user?: { name: string }
|
||||
onLogin?: () => void
|
||||
onLogout?: () => void
|
||||
onCreateAccount?: () => void
|
||||
}
|
||||
|
||||
const { user, onLogin, onLogout, onCreateAccount }: Props = $props();
|
||||
const { user, onLogin, onLogout, onCreateAccount }: Props = $props()
|
||||
</script>
|
||||
|
||||
<header>
|
||||
<div class="storybook-header">
|
||||
<div>
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
|
||||
<g fill="none" fill-rule="evenodd">
|
||||
<path
|
||||
d="M10 0h12a10 10 0 0110 10v12a10 10 0 01-10 10H10A10 10 0 010 22V10A10 10 0 0110 0z"
|
||||
fill="#FFF"
|
||||
/>
|
||||
<path
|
||||
d="M5.3 10.6l10.4 6v11.1l-10.4-6v-11zm11.4-6.2l9.7 5.5-9.7 5.6V4.4z"
|
||||
fill="#555AB9"
|
||||
/>
|
||||
<path d="M27.2 10.6v11.2l-10.5 6V16.5l10.5-6zM15.7 4.4v11L6 10l9.7-5.5z" fill="#91BAF8" />
|
||||
</g>
|
||||
</svg>
|
||||
<h1>Acme</h1>
|
||||
</div>
|
||||
<div>
|
||||
{#if user}
|
||||
<span class="welcome">
|
||||
Welcome, <b>{user.name}</b>!
|
||||
</span>
|
||||
<Button buttonSize="small" onclick={onLogout} label="Log out" />
|
||||
{:else}
|
||||
<Button buttonSize="small" onclick={onLogin} label="Log in" />
|
||||
<Button primary buttonSize="small" onclick={onCreateAccount} label="Sign up" />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="storybook-header">
|
||||
<div>
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
|
||||
<g fill="none" fill-rule="evenodd">
|
||||
<path
|
||||
d="M10 0h12a10 10 0 0110 10v12a10 10 0 01-10 10H10A10 10 0 010 22V10A10 10 0 0110 0z"
|
||||
fill="#FFF"
|
||||
/>
|
||||
<path
|
||||
d="M5.3 10.6l10.4 6v11.1l-10.4-6v-11zm11.4-6.2l9.7 5.5-9.7 5.6V4.4z"
|
||||
fill="#555AB9"
|
||||
/>
|
||||
<path d="M27.2 10.6v11.2l-10.5 6V16.5l10.5-6zM15.7 4.4v11L6 10l9.7-5.5z" fill="#91BAF8" />
|
||||
</g>
|
||||
</svg>
|
||||
<h1>Acme</h1>
|
||||
</div>
|
||||
<div>
|
||||
{#if user}
|
||||
<span class="welcome">
|
||||
Welcome, <b>{user.name}</b>!
|
||||
</span>
|
||||
<Button buttonSize="small" onclick={onLogout} label="Log out" />
|
||||
{:else}
|
||||
<Button buttonSize="small" onclick={onLogin} label="Log in" />
|
||||
<Button primary buttonSize="small" onclick={onCreateAccount} label="Sign up" />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
|
|
|||
|
|
@ -1,30 +1,32 @@
|
|||
<script module>
|
||||
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||
import { expect, userEvent, waitFor, within } from 'storybook/test';
|
||||
import Page from './Page.svelte';
|
||||
import { fn } from 'storybook/test';
|
||||
import { defineMeta } from '@storybook/addon-svelte-csf'
|
||||
import { expect, userEvent, waitFor, within } from 'storybook/test'
|
||||
import Page from './Page.svelte'
|
||||
import { fn } from 'storybook/test'
|
||||
|
||||
// More on how to set up stories at: https://storybook.js.org/docs/writing-stories
|
||||
const { Story } = defineMeta({
|
||||
title: 'Example/Page',
|
||||
component: Page,
|
||||
parameters: {
|
||||
// More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
|
||||
layout: 'fullscreen',
|
||||
},
|
||||
});
|
||||
// More on how to set up stories at: https://storybook.js.org/docs/writing-stories
|
||||
const { Story } = defineMeta({
|
||||
title: 'Example/Page',
|
||||
component: Page,
|
||||
parameters: {
|
||||
// More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
|
||||
layout: 'fullscreen'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<Story name="Logged In" play={async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const loginButton = canvas.getByRole('button', { name: /Log in/i });
|
||||
await expect(loginButton).toBeInTheDocument();
|
||||
await userEvent.click(loginButton);
|
||||
await waitFor(() => expect(loginButton).not.toBeInTheDocument());
|
||||
<Story
|
||||
name="Logged In"
|
||||
play={async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement)
|
||||
const loginButton = canvas.getByRole('button', { name: /Log in/i })
|
||||
await expect(loginButton).toBeInTheDocument()
|
||||
await userEvent.click(loginButton)
|
||||
await waitFor(() => expect(loginButton).not.toBeInTheDocument())
|
||||
|
||||
const logoutButton = canvas.getByRole('button', { name: /Log out/i });
|
||||
await expect(logoutButton).toBeInTheDocument();
|
||||
}}
|
||||
const logoutButton = canvas.getByRole('button', { name: /Log out/i })
|
||||
await expect(logoutButton).toBeInTheDocument()
|
||||
}}
|
||||
/>
|
||||
|
||||
<Story name="Logged Out" />
|
||||
|
|
|
|||
|
|
@ -1,70 +1,70 @@
|
|||
<script lang="ts">
|
||||
import './page.css';
|
||||
import Header from './Header.svelte';
|
||||
import './page.css'
|
||||
import Header from './Header.svelte'
|
||||
|
||||
let user = $state<{ name: string }>();
|
||||
let user = $state<{ name: string }>()
|
||||
</script>
|
||||
|
||||
<article>
|
||||
<Header
|
||||
{user}
|
||||
onLogin={() => (user = { name: 'Jane Doe' })}
|
||||
onLogout={() => (user = undefined)}
|
||||
onCreateAccount={() => (user = { name: 'Jane Doe' })}
|
||||
/>
|
||||
<Header
|
||||
{user}
|
||||
onLogin={() => (user = { name: 'Jane Doe' })}
|
||||
onLogout={() => (user = undefined)}
|
||||
onCreateAccount={() => (user = { name: 'Jane Doe' })}
|
||||
/>
|
||||
|
||||
<section class="storybook-page">
|
||||
<h2>Pages in Storybook</h2>
|
||||
<p>
|
||||
We recommend building UIs with a
|
||||
<a
|
||||
href="https://blog.hichroma.com/component-driven-development-ce1109d56c8e"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<strong>component-driven</strong>
|
||||
</a>
|
||||
process starting with atomic components and ending with pages.
|
||||
</p>
|
||||
<p>
|
||||
Render pages with mock data. This makes it easy to build and review page states without
|
||||
needing to navigate to them in your app. Here are some handy patterns for managing page data
|
||||
in Storybook:
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
Use a higher-level connected component. Storybook helps you compose such data from the
|
||||
"args" of child component stories
|
||||
</li>
|
||||
<li>
|
||||
Assemble data in the page component from your services. You can mock these services out
|
||||
using Storybook.
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
Get a guided tutorial on component-driven development at
|
||||
<a href="https://storybook.js.org/tutorials/" target="_blank" rel="noopener noreferrer">
|
||||
Storybook tutorials
|
||||
</a>
|
||||
. Read more in the
|
||||
<a href="https://storybook.js.org/docs" target="_blank" rel="noopener noreferrer">docs</a>
|
||||
.
|
||||
</p>
|
||||
<div class="tip-wrapper">
|
||||
<span class="tip">Tip</span>
|
||||
Adjust the width of the canvas with the
|
||||
<svg width="10" height="10" viewBox="0 0 12 12" xmlns="http://www.w3.org/2000/svg">
|
||||
<g fill="none" fill-rule="evenodd">
|
||||
<path
|
||||
d="M1.5 5.2h4.8c.3 0 .5.2.5.4v5.1c-.1.2-.3.3-.4.3H1.4a.5.5 0
|
||||
<section class="storybook-page">
|
||||
<h2>Pages in Storybook</h2>
|
||||
<p>
|
||||
We recommend building UIs with a
|
||||
<a
|
||||
href="https://blog.hichroma.com/component-driven-development-ce1109d56c8e"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<strong>component-driven</strong>
|
||||
</a>
|
||||
process starting with atomic components and ending with pages.
|
||||
</p>
|
||||
<p>
|
||||
Render pages with mock data. This makes it easy to build and review page states without
|
||||
needing to navigate to them in your app. Here are some handy patterns for managing page data
|
||||
in Storybook:
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
Use a higher-level connected component. Storybook helps you compose such data from the
|
||||
"args" of child component stories
|
||||
</li>
|
||||
<li>
|
||||
Assemble data in the page component from your services. You can mock these services out
|
||||
using Storybook.
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
Get a guided tutorial on component-driven development at
|
||||
<a href="https://storybook.js.org/tutorials/" target="_blank" rel="noopener noreferrer">
|
||||
Storybook tutorials
|
||||
</a>
|
||||
. Read more in the
|
||||
<a href="https://storybook.js.org/docs" target="_blank" rel="noopener noreferrer">docs</a>
|
||||
.
|
||||
</p>
|
||||
<div class="tip-wrapper">
|
||||
<span class="tip">Tip</span>
|
||||
Adjust the width of the canvas with the
|
||||
<svg width="10" height="10" viewBox="0 0 12 12" xmlns="http://www.w3.org/2000/svg">
|
||||
<g fill="none" fill-rule="evenodd">
|
||||
<path
|
||||
d="M1.5 5.2h4.8c.3 0 .5.2.5.4v5.1c-.1.2-.3.3-.4.3H1.4a.5.5 0
|
||||
01-.5-.4V5.7c0-.3.2-.5.5-.5zm0-2.1h6.9c.3 0 .5.2.5.4v7a.5.5 0 01-1 0V4H1.5a.5.5 0
|
||||
010-1zm0-2.1h9c.3 0 .5.2.5.4v9.1a.5.5 0 01-1 0V2H1.5a.5.5 0 010-1zm4.3 5.2H2V10h3.8V6.2z"
|
||||
id="a"
|
||||
fill="#999"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
Viewports addon in the toolbar
|
||||
</div>
|
||||
</section>
|
||||
id="a"
|
||||
fill="#999"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
Viewports addon in the toolbar
|
||||
</div>
|
||||
</section>
|
||||
</article>
|
||||
|
|
|
|||
|
|
@ -1,128 +1,128 @@
|
|||
import Button from '$lib/components/admin/Button.svelte';
|
||||
import ButtonShowcase from './ButtonShowcase.svelte';
|
||||
import Button from '$lib/components/admin/Button.svelte'
|
||||
import ButtonShowcase from './ButtonShowcase.svelte'
|
||||
|
||||
export default {
|
||||
title: 'Admin/Button',
|
||||
component: Button,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
variant: {
|
||||
control: { type: 'select' },
|
||||
options: ['primary', 'secondary', 'danger', 'ghost', 'text', 'overlay']
|
||||
},
|
||||
size: {
|
||||
control: { type: 'select' },
|
||||
options: ['small', 'medium', 'large', 'icon']
|
||||
},
|
||||
iconPosition: {
|
||||
control: { type: 'select' },
|
||||
options: ['left', 'right']
|
||||
},
|
||||
iconOnly: {
|
||||
control: 'boolean'
|
||||
},
|
||||
pill: {
|
||||
control: 'boolean'
|
||||
},
|
||||
fullWidth: {
|
||||
control: 'boolean'
|
||||
},
|
||||
loading: {
|
||||
control: 'boolean'
|
||||
},
|
||||
active: {
|
||||
control: 'boolean'
|
||||
},
|
||||
disabled: {
|
||||
control: 'boolean'
|
||||
},
|
||||
onclick: { action: 'clicked' }
|
||||
}
|
||||
};
|
||||
title: 'Admin/Button',
|
||||
component: Button,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
variant: {
|
||||
control: { type: 'select' },
|
||||
options: ['primary', 'secondary', 'danger', 'ghost', 'text', 'overlay']
|
||||
},
|
||||
size: {
|
||||
control: { type: 'select' },
|
||||
options: ['small', 'medium', 'large', 'icon']
|
||||
},
|
||||
iconPosition: {
|
||||
control: { type: 'select' },
|
||||
options: ['left', 'right']
|
||||
},
|
||||
iconOnly: {
|
||||
control: 'boolean'
|
||||
},
|
||||
pill: {
|
||||
control: 'boolean'
|
||||
},
|
||||
fullWidth: {
|
||||
control: 'boolean'
|
||||
},
|
||||
loading: {
|
||||
control: 'boolean'
|
||||
},
|
||||
active: {
|
||||
control: 'boolean'
|
||||
},
|
||||
disabled: {
|
||||
control: 'boolean'
|
||||
},
|
||||
onclick: { action: 'clicked' }
|
||||
}
|
||||
}
|
||||
|
||||
// Interactive Playground (this will be the default story for the controls)
|
||||
export const Playground = {
|
||||
args: {
|
||||
variant: 'primary',
|
||||
size: 'medium',
|
||||
pill: true,
|
||||
iconOnly: false,
|
||||
fullWidth: false,
|
||||
loading: false,
|
||||
active: false,
|
||||
disabled: false
|
||||
}
|
||||
};
|
||||
args: {
|
||||
variant: 'primary',
|
||||
size: 'medium',
|
||||
pill: true,
|
||||
iconOnly: false,
|
||||
fullWidth: false,
|
||||
loading: false,
|
||||
active: false,
|
||||
disabled: false
|
||||
}
|
||||
}
|
||||
|
||||
// Primary story
|
||||
export const Primary = {
|
||||
args: {
|
||||
variant: 'primary'
|
||||
}
|
||||
};
|
||||
args: {
|
||||
variant: 'primary'
|
||||
}
|
||||
}
|
||||
|
||||
// Secondary story
|
||||
export const Secondary = {
|
||||
args: {
|
||||
variant: 'secondary'
|
||||
}
|
||||
};
|
||||
args: {
|
||||
variant: 'secondary'
|
||||
}
|
||||
}
|
||||
|
||||
// Danger story
|
||||
export const Danger = {
|
||||
args: {
|
||||
variant: 'danger'
|
||||
}
|
||||
};
|
||||
args: {
|
||||
variant: 'danger'
|
||||
}
|
||||
}
|
||||
|
||||
// Ghost story
|
||||
export const Ghost = {
|
||||
args: {
|
||||
variant: 'ghost'
|
||||
}
|
||||
};
|
||||
args: {
|
||||
variant: 'ghost'
|
||||
}
|
||||
}
|
||||
|
||||
// Text story
|
||||
export const Text = {
|
||||
args: {
|
||||
variant: 'text'
|
||||
}
|
||||
};
|
||||
args: {
|
||||
variant: 'text'
|
||||
}
|
||||
}
|
||||
|
||||
// Overlay story
|
||||
export const Overlay = {
|
||||
args: {
|
||||
variant: 'overlay'
|
||||
}
|
||||
};
|
||||
args: {
|
||||
variant: 'overlay'
|
||||
}
|
||||
}
|
||||
|
||||
// Loading State
|
||||
export const Loading = {
|
||||
args: {
|
||||
variant: 'primary',
|
||||
loading: true
|
||||
}
|
||||
};
|
||||
args: {
|
||||
variant: 'primary',
|
||||
loading: true
|
||||
}
|
||||
}
|
||||
|
||||
// Disabled State
|
||||
export const Disabled = {
|
||||
args: {
|
||||
variant: 'primary',
|
||||
disabled: true
|
||||
}
|
||||
};
|
||||
args: {
|
||||
variant: 'primary',
|
||||
disabled: true
|
||||
}
|
||||
}
|
||||
|
||||
// Full Width
|
||||
export const FullWidth = {
|
||||
args: {
|
||||
variant: 'primary',
|
||||
fullWidth: true
|
||||
}
|
||||
};
|
||||
args: {
|
||||
variant: 'primary',
|
||||
fullWidth: true
|
||||
}
|
||||
}
|
||||
|
||||
// All variants showcase
|
||||
export const AllVariants = {
|
||||
render: () => ({
|
||||
Component: ButtonShowcase
|
||||
})
|
||||
};
|
||||
render: () => ({
|
||||
Component: ButtonShowcase
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,111 +1,164 @@
|
|||
<script>
|
||||
import Button from '$lib/components/admin/Button.svelte';
|
||||
import Button from '$lib/components/admin/Button.svelte'
|
||||
</script>
|
||||
|
||||
<div class="button-showcase">
|
||||
<h3>Button Variants</h3>
|
||||
<h3>Button Variants</h3>
|
||||
|
||||
<div class="section">
|
||||
<h4>Basic Variants</h4>
|
||||
<div class="button-grid">
|
||||
<Button variant="primary">Primary</Button>
|
||||
<Button variant="secondary">Secondary</Button>
|
||||
<Button variant="danger">Danger</Button>
|
||||
<Button variant="ghost">Ghost</Button>
|
||||
<Button variant="text">Text</Button>
|
||||
<Button variant="overlay">Overlay</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section">
|
||||
<h4>Basic Variants</h4>
|
||||
<div class="button-grid">
|
||||
<Button variant="primary">Primary</Button>
|
||||
<Button variant="secondary">Secondary</Button>
|
||||
<Button variant="danger">Danger</Button>
|
||||
<Button variant="ghost">Ghost</Button>
|
||||
<Button variant="text">Text</Button>
|
||||
<Button variant="overlay">Overlay</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h4>Sizes</h4>
|
||||
<div class="button-grid">
|
||||
<Button variant="primary" buttonSize="small">Small</Button>
|
||||
<Button variant="primary" buttonSize="medium">Medium</Button>
|
||||
<Button variant="primary" buttonSize="large">Large</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section">
|
||||
<h4>Sizes</h4>
|
||||
<div class="button-grid">
|
||||
<Button variant="primary" buttonSize="small">Small</Button>
|
||||
<Button variant="primary" buttonSize="medium">Medium</Button>
|
||||
<Button variant="primary" buttonSize="large">Large</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h4>With Icons</h4>
|
||||
<div class="button-grid">
|
||||
<Button variant="primary" iconPosition="left">
|
||||
<svg slot="icon" width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 5V19M5 12H19" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
Add Item
|
||||
</Button>
|
||||
<div class="section">
|
||||
<h4>With Icons</h4>
|
||||
<div class="button-grid">
|
||||
<Button variant="primary" iconPosition="left">
|
||||
<svg
|
||||
slot="icon"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12 5V19M5 12H19"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
Add Item
|
||||
</Button>
|
||||
|
||||
<Button variant="secondary" iconPosition="right">
|
||||
Download
|
||||
<svg slot="icon" width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M21 15V19A2 2 0 0119 21H5A2 2 0 013 19V15" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<polyline points="7,10 12,15 17,10" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<line x1="12" y1="15" x2="12" y2="3" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</Button>
|
||||
<Button variant="secondary" iconPosition="right">
|
||||
Download
|
||||
<svg
|
||||
slot="icon"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M21 15V19A2 2 0 0119 21H5A2 2 0 013 19V15"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<polyline
|
||||
points="7,10 12,15 17,10"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<line
|
||||
x1="12"
|
||||
y1="15"
|
||||
x2="12"
|
||||
y2="3"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
|
||||
<Button variant="ghost" iconOnly={true}>
|
||||
<svg slot="icon" width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6 6L18 18M6 18L18 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" iconOnly={true}>
|
||||
<svg
|
||||
slot="icon"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M6 6L18 18M6 18L18 6"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h4>States</h4>
|
||||
<div class="button-grid">
|
||||
<Button variant="primary" loading={true}>Loading...</Button>
|
||||
<Button variant="secondary" disabled={true}>Disabled</Button>
|
||||
<Button variant="ghost" active={true}>Active</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section">
|
||||
<h4>States</h4>
|
||||
<div class="button-grid">
|
||||
<Button variant="primary" loading={true}>Loading...</Button>
|
||||
<Button variant="secondary" disabled={true}>Disabled</Button>
|
||||
<Button variant="ghost" active={true}>Active</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h4>Square Corners</h4>
|
||||
<div class="button-grid">
|
||||
<Button variant="primary" pill={false}>Square Primary</Button>
|
||||
<Button variant="secondary" pill={false}>Square Secondary</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section">
|
||||
<h4>Square Corners</h4>
|
||||
<div class="button-grid">
|
||||
<Button variant="primary" pill={false}>Square Primary</Button>
|
||||
<Button variant="secondary" pill={false}>Square Secondary</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h4>Full Width</h4>
|
||||
<Button variant="primary" fullWidth={true}>Full Width Button</Button>
|
||||
</div>
|
||||
<div class="section">
|
||||
<h4>Full Width</h4>
|
||||
<Button variant="primary" fullWidth={true}>Full Width Button</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.button-showcase {
|
||||
max-width: 600px;
|
||||
padding: 20px;
|
||||
}
|
||||
.button-showcase {
|
||||
max-width: 600px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0 0 24px 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
h3 {
|
||||
margin: 0 0 24px 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 32px;
|
||||
.section {
|
||||
margin-bottom: 32px;
|
||||
|
||||
h4 {
|
||||
margin: 0 0 16px 0;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #666;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
}
|
||||
h4 {
|
||||
margin: 0 0 16px 0;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #666;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
}
|
||||
|
||||
.button-grid {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
.button-grid {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,169 +1,169 @@
|
|||
import ImageUploader from '$lib/components/admin/ImageUploader.svelte';
|
||||
import ImageUploader from '$lib/components/admin/ImageUploader.svelte'
|
||||
|
||||
// Mock Media object for testing
|
||||
const mockMedia = {
|
||||
id: 1,
|
||||
filename: 'sample-image.jpg',
|
||||
originalName: 'sample-image.jpg',
|
||||
mimeType: 'image/jpeg',
|
||||
size: 1024000,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
url: 'https://via.placeholder.com/400x300/0066cc/ffffff?text=Sample+Image',
|
||||
thumbnailUrl: 'https://via.placeholder.com/400x300/0066cc/ffffff?text=Sample+Image',
|
||||
altText: 'A beautiful sample image',
|
||||
description: 'This is a sample image for testing purposes',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
};
|
||||
id: 1,
|
||||
filename: 'sample-image.jpg',
|
||||
originalName: 'sample-image.jpg',
|
||||
mimeType: 'image/jpeg',
|
||||
size: 1024000,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
url: 'https://via.placeholder.com/400x300/0066cc/ffffff?text=Sample+Image',
|
||||
thumbnailUrl: 'https://via.placeholder.com/400x300/0066cc/ffffff?text=Sample+Image',
|
||||
altText: 'A beautiful sample image',
|
||||
description: 'This is a sample image for testing purposes',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
}
|
||||
|
||||
export default {
|
||||
title: 'Admin/Form Components/ImageUploader',
|
||||
component: ImageUploader,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
aspectRatio: {
|
||||
control: { type: 'select' },
|
||||
options: ['', '1:1', '16:9', '4:3', '3:2']
|
||||
},
|
||||
required: {
|
||||
control: 'boolean'
|
||||
},
|
||||
allowAltText: {
|
||||
control: 'boolean'
|
||||
},
|
||||
showBrowseLibrary: {
|
||||
control: 'boolean'
|
||||
},
|
||||
maxFileSize: {
|
||||
control: 'number'
|
||||
},
|
||||
label: {
|
||||
control: 'text'
|
||||
},
|
||||
placeholder: {
|
||||
control: 'text'
|
||||
},
|
||||
helpText: {
|
||||
control: 'text'
|
||||
},
|
||||
error: {
|
||||
control: 'text'
|
||||
}
|
||||
}
|
||||
};
|
||||
title: 'Admin/Form Components/ImageUploader',
|
||||
component: ImageUploader,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
aspectRatio: {
|
||||
control: { type: 'select' },
|
||||
options: ['', '1:1', '16:9', '4:3', '3:2']
|
||||
},
|
||||
required: {
|
||||
control: 'boolean'
|
||||
},
|
||||
allowAltText: {
|
||||
control: 'boolean'
|
||||
},
|
||||
showBrowseLibrary: {
|
||||
control: 'boolean'
|
||||
},
|
||||
maxFileSize: {
|
||||
control: 'number'
|
||||
},
|
||||
label: {
|
||||
control: 'text'
|
||||
},
|
||||
placeholder: {
|
||||
control: 'text'
|
||||
},
|
||||
helpText: {
|
||||
control: 'text'
|
||||
},
|
||||
error: {
|
||||
control: 'text'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Empty uploader
|
||||
export const Empty = {
|
||||
args: {
|
||||
label: 'Featured Image',
|
||||
placeholder: 'Drag and drop an image here, or click to browse',
|
||||
allowAltText: true,
|
||||
required: false,
|
||||
maxFileSize: 10
|
||||
}
|
||||
};
|
||||
args: {
|
||||
label: 'Featured Image',
|
||||
placeholder: 'Drag and drop an image here, or click to browse',
|
||||
allowAltText: true,
|
||||
required: false,
|
||||
maxFileSize: 10
|
||||
}
|
||||
}
|
||||
|
||||
// With uploaded image
|
||||
export const WithImage = {
|
||||
args: {
|
||||
label: 'Project Logo',
|
||||
value: mockMedia,
|
||||
allowAltText: true,
|
||||
aspectRatio: '1:1'
|
||||
}
|
||||
};
|
||||
args: {
|
||||
label: 'Project Logo',
|
||||
value: mockMedia,
|
||||
allowAltText: true,
|
||||
aspectRatio: '1:1'
|
||||
}
|
||||
}
|
||||
|
||||
// Square aspect ratio
|
||||
export const SquareAspectRatio = {
|
||||
args: {
|
||||
label: 'Square Logo',
|
||||
aspectRatio: '1:1',
|
||||
placeholder: 'Upload a square logo',
|
||||
allowAltText: true,
|
||||
required: true
|
||||
}
|
||||
};
|
||||
args: {
|
||||
label: 'Square Logo',
|
||||
aspectRatio: '1:1',
|
||||
placeholder: 'Upload a square logo',
|
||||
allowAltText: true,
|
||||
required: true
|
||||
}
|
||||
}
|
||||
|
||||
// Wide aspect ratio
|
||||
export const WideAspectRatio = {
|
||||
args: {
|
||||
label: 'Hero Banner',
|
||||
aspectRatio: '16:9',
|
||||
placeholder: 'Upload a banner image',
|
||||
allowAltText: true,
|
||||
helpText: 'Recommended size: 1920x1080 pixels'
|
||||
}
|
||||
};
|
||||
args: {
|
||||
label: 'Hero Banner',
|
||||
aspectRatio: '16:9',
|
||||
placeholder: 'Upload a banner image',
|
||||
allowAltText: true,
|
||||
helpText: 'Recommended size: 1920x1080 pixels'
|
||||
}
|
||||
}
|
||||
|
||||
// Required field
|
||||
export const Required = {
|
||||
args: {
|
||||
label: 'Required Image',
|
||||
required: true,
|
||||
allowAltText: true,
|
||||
placeholder: 'This field is required'
|
||||
}
|
||||
};
|
||||
args: {
|
||||
label: 'Required Image',
|
||||
required: true,
|
||||
allowAltText: true,
|
||||
placeholder: 'This field is required'
|
||||
}
|
||||
}
|
||||
|
||||
// With error
|
||||
export const WithError = {
|
||||
args: {
|
||||
label: 'Image with Error',
|
||||
error: 'Please select a valid image file',
|
||||
allowAltText: true
|
||||
}
|
||||
};
|
||||
args: {
|
||||
label: 'Image with Error',
|
||||
error: 'Please select a valid image file',
|
||||
allowAltText: true
|
||||
}
|
||||
}
|
||||
|
||||
// With help text
|
||||
export const WithHelpText = {
|
||||
args: {
|
||||
label: 'Profile Picture',
|
||||
helpText: 'Upload a clear photo of yourself. This will be displayed on your profile.',
|
||||
allowAltText: true,
|
||||
aspectRatio: '1:1',
|
||||
maxFileSize: 5
|
||||
}
|
||||
};
|
||||
args: {
|
||||
label: 'Profile Picture',
|
||||
helpText: 'Upload a clear photo of yourself. This will be displayed on your profile.',
|
||||
allowAltText: true,
|
||||
aspectRatio: '1:1',
|
||||
maxFileSize: 5
|
||||
}
|
||||
}
|
||||
|
||||
// Without alt text
|
||||
export const WithoutAltText = {
|
||||
args: {
|
||||
label: 'Decorative Image',
|
||||
allowAltText: false,
|
||||
placeholder: 'Upload a decorative image',
|
||||
helpText: 'This image is purely decorative and does not need alt text.'
|
||||
}
|
||||
};
|
||||
args: {
|
||||
label: 'Decorative Image',
|
||||
allowAltText: false,
|
||||
placeholder: 'Upload a decorative image',
|
||||
helpText: 'This image is purely decorative and does not need alt text.'
|
||||
}
|
||||
}
|
||||
|
||||
// With browse library option
|
||||
export const WithBrowseLibrary = {
|
||||
args: {
|
||||
label: 'Featured Image',
|
||||
allowAltText: true,
|
||||
showBrowseLibrary: true,
|
||||
placeholder: 'Upload a new image or browse existing ones'
|
||||
}
|
||||
};
|
||||
args: {
|
||||
label: 'Featured Image',
|
||||
allowAltText: true,
|
||||
showBrowseLibrary: true,
|
||||
placeholder: 'Upload a new image or browse existing ones'
|
||||
}
|
||||
}
|
||||
|
||||
// Small file size limit
|
||||
export const SmallFileLimit = {
|
||||
args: {
|
||||
label: 'Small Image',
|
||||
maxFileSize: 1,
|
||||
allowAltText: true,
|
||||
helpText: 'Maximum file size: 1MB'
|
||||
}
|
||||
};
|
||||
args: {
|
||||
label: 'Small Image',
|
||||
maxFileSize: 1,
|
||||
allowAltText: true,
|
||||
helpText: 'Maximum file size: 1MB'
|
||||
}
|
||||
}
|
||||
|
||||
// Interactive playground
|
||||
export const Playground = {
|
||||
args: {
|
||||
label: 'Image Upload',
|
||||
placeholder: 'Drag and drop an image here',
|
||||
allowAltText: true,
|
||||
required: false,
|
||||
maxFileSize: 10,
|
||||
showBrowseLibrary: false
|
||||
}
|
||||
};
|
||||
args: {
|
||||
label: 'Image Upload',
|
||||
placeholder: 'Drag and drop an image here',
|
||||
allowAltText: true,
|
||||
required: false,
|
||||
maxFileSize: 10,
|
||||
showBrowseLibrary: false
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,274 +1,286 @@
|
|||
import Input from '$lib/components/admin/Input.svelte';
|
||||
import Input from '$lib/components/admin/Input.svelte'
|
||||
|
||||
export default {
|
||||
title: 'Admin/Input',
|
||||
component: Input,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
type: {
|
||||
control: { type: 'select' },
|
||||
options: ['text', 'email', 'password', 'url', 'search', 'number', 'tel', 'date', 'time', 'color', 'textarea']
|
||||
},
|
||||
size: {
|
||||
control: { type: 'select' },
|
||||
options: ['small', 'medium', 'large']
|
||||
},
|
||||
fullWidth: {
|
||||
control: 'boolean'
|
||||
},
|
||||
required: {
|
||||
control: 'boolean'
|
||||
},
|
||||
disabled: {
|
||||
control: 'boolean'
|
||||
},
|
||||
readonly: {
|
||||
control: 'boolean'
|
||||
},
|
||||
prefixIcon: {
|
||||
control: 'boolean'
|
||||
},
|
||||
suffixIcon: {
|
||||
control: 'boolean'
|
||||
},
|
||||
showCharCount: {
|
||||
control: 'boolean'
|
||||
},
|
||||
label: {
|
||||
control: 'text'
|
||||
},
|
||||
placeholder: {
|
||||
control: 'text'
|
||||
},
|
||||
helpText: {
|
||||
control: 'text'
|
||||
},
|
||||
error: {
|
||||
control: 'text'
|
||||
},
|
||||
maxLength: {
|
||||
control: 'number'
|
||||
}
|
||||
}
|
||||
};
|
||||
title: 'Admin/Input',
|
||||
component: Input,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
type: {
|
||||
control: { type: 'select' },
|
||||
options: [
|
||||
'text',
|
||||
'email',
|
||||
'password',
|
||||
'url',
|
||||
'search',
|
||||
'number',
|
||||
'tel',
|
||||
'date',
|
||||
'time',
|
||||
'color',
|
||||
'textarea'
|
||||
]
|
||||
},
|
||||
size: {
|
||||
control: { type: 'select' },
|
||||
options: ['small', 'medium', 'large']
|
||||
},
|
||||
fullWidth: {
|
||||
control: 'boolean'
|
||||
},
|
||||
required: {
|
||||
control: 'boolean'
|
||||
},
|
||||
disabled: {
|
||||
control: 'boolean'
|
||||
},
|
||||
readonly: {
|
||||
control: 'boolean'
|
||||
},
|
||||
prefixIcon: {
|
||||
control: 'boolean'
|
||||
},
|
||||
suffixIcon: {
|
||||
control: 'boolean'
|
||||
},
|
||||
showCharCount: {
|
||||
control: 'boolean'
|
||||
},
|
||||
label: {
|
||||
control: 'text'
|
||||
},
|
||||
placeholder: {
|
||||
control: 'text'
|
||||
},
|
||||
helpText: {
|
||||
control: 'text'
|
||||
},
|
||||
error: {
|
||||
control: 'text'
|
||||
},
|
||||
maxLength: {
|
||||
control: 'number'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Interactive Playground
|
||||
export const Playground = {
|
||||
args: {
|
||||
type: 'text',
|
||||
label: 'Your Name',
|
||||
placeholder: 'Enter your name',
|
||||
size: 'medium',
|
||||
fullWidth: true,
|
||||
required: false,
|
||||
disabled: false,
|
||||
readonly: false
|
||||
}
|
||||
};
|
||||
args: {
|
||||
type: 'text',
|
||||
label: 'Your Name',
|
||||
placeholder: 'Enter your name',
|
||||
size: 'medium',
|
||||
fullWidth: true,
|
||||
required: false,
|
||||
disabled: false,
|
||||
readonly: false
|
||||
}
|
||||
}
|
||||
|
||||
// Basic text input
|
||||
export const Basic = {
|
||||
args: {
|
||||
type: 'text',
|
||||
label: 'Basic Input',
|
||||
placeholder: 'Type something...'
|
||||
}
|
||||
};
|
||||
args: {
|
||||
type: 'text',
|
||||
label: 'Basic Input',
|
||||
placeholder: 'Type something...'
|
||||
}
|
||||
}
|
||||
|
||||
// Email input
|
||||
export const Email = {
|
||||
args: {
|
||||
type: 'email',
|
||||
label: 'Email Address',
|
||||
placeholder: 'you@example.com',
|
||||
required: true
|
||||
}
|
||||
};
|
||||
args: {
|
||||
type: 'email',
|
||||
label: 'Email Address',
|
||||
placeholder: 'you@example.com',
|
||||
required: true
|
||||
}
|
||||
}
|
||||
|
||||
// Password input
|
||||
export const Password = {
|
||||
args: {
|
||||
type: 'password',
|
||||
label: 'Password',
|
||||
placeholder: 'Enter your password',
|
||||
required: true
|
||||
}
|
||||
};
|
||||
args: {
|
||||
type: 'password',
|
||||
label: 'Password',
|
||||
placeholder: 'Enter your password',
|
||||
required: true
|
||||
}
|
||||
}
|
||||
|
||||
// Search input
|
||||
export const Search = {
|
||||
args: {
|
||||
type: 'search',
|
||||
label: 'Search',
|
||||
placeholder: 'Search for something...'
|
||||
}
|
||||
};
|
||||
args: {
|
||||
type: 'search',
|
||||
label: 'Search',
|
||||
placeholder: 'Search for something...'
|
||||
}
|
||||
}
|
||||
|
||||
// Number input
|
||||
export const Number = {
|
||||
args: {
|
||||
type: 'number',
|
||||
label: 'Age',
|
||||
placeholder: '25',
|
||||
min: 0,
|
||||
max: 120
|
||||
}
|
||||
};
|
||||
args: {
|
||||
type: 'number',
|
||||
label: 'Age',
|
||||
placeholder: '25',
|
||||
min: 0,
|
||||
max: 120
|
||||
}
|
||||
}
|
||||
|
||||
// Textarea
|
||||
export const Textarea = {
|
||||
args: {
|
||||
type: 'textarea',
|
||||
label: 'Description',
|
||||
placeholder: 'Tell us about yourself...',
|
||||
rows: 4
|
||||
}
|
||||
};
|
||||
args: {
|
||||
type: 'textarea',
|
||||
label: 'Description',
|
||||
placeholder: 'Tell us about yourself...',
|
||||
rows: 4
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-resizing textarea
|
||||
export const AutoResizeTextarea = {
|
||||
args: {
|
||||
type: 'textarea',
|
||||
label: 'Auto-resize Textarea',
|
||||
placeholder: 'This textarea will grow as you type...',
|
||||
autoResize: true,
|
||||
rows: 2
|
||||
}
|
||||
};
|
||||
args: {
|
||||
type: 'textarea',
|
||||
label: 'Auto-resize Textarea',
|
||||
placeholder: 'This textarea will grow as you type...',
|
||||
autoResize: true,
|
||||
rows: 2
|
||||
}
|
||||
}
|
||||
|
||||
// With help text
|
||||
export const WithHelpText = {
|
||||
args: {
|
||||
type: 'email',
|
||||
label: 'Email',
|
||||
placeholder: 'you@example.com',
|
||||
helpText: 'We will never share your email with anyone else.'
|
||||
}
|
||||
};
|
||||
args: {
|
||||
type: 'email',
|
||||
label: 'Email',
|
||||
placeholder: 'you@example.com',
|
||||
helpText: 'We will never share your email with anyone else.'
|
||||
}
|
||||
}
|
||||
|
||||
// With error
|
||||
export const WithError = {
|
||||
args: {
|
||||
type: 'email',
|
||||
label: 'Email',
|
||||
value: 'invalid-email',
|
||||
error: 'Please enter a valid email address.',
|
||||
required: true
|
||||
}
|
||||
};
|
||||
args: {
|
||||
type: 'email',
|
||||
label: 'Email',
|
||||
value: 'invalid-email',
|
||||
error: 'Please enter a valid email address.',
|
||||
required: true
|
||||
}
|
||||
}
|
||||
|
||||
// Character count
|
||||
export const CharacterCount = {
|
||||
args: {
|
||||
type: 'textarea',
|
||||
label: 'Bio',
|
||||
placeholder: 'Tell us about yourself...',
|
||||
maxLength: 150,
|
||||
showCharCount: true,
|
||||
rows: 3
|
||||
}
|
||||
};
|
||||
args: {
|
||||
type: 'textarea',
|
||||
label: 'Bio',
|
||||
placeholder: 'Tell us about yourself...',
|
||||
maxLength: 150,
|
||||
showCharCount: true,
|
||||
rows: 3
|
||||
}
|
||||
}
|
||||
|
||||
// Different sizes
|
||||
export const Sizes = {
|
||||
render: () => ({
|
||||
template: `
|
||||
render: () => ({
|
||||
template: `
|
||||
<div style="display: flex; flex-direction: column; gap: 16px; max-width: 400px;">
|
||||
<Input type="text" label="Small Size" placeholder="Small input" size="small" />
|
||||
<Input type="text" label="Medium Size" placeholder="Medium input" size="medium" />
|
||||
<Input type="text" label="Large Size" placeholder="Large input" size="large" />
|
||||
</div>
|
||||
`,
|
||||
components: { Input }
|
||||
})
|
||||
};
|
||||
components: { Input }
|
||||
})
|
||||
}
|
||||
|
||||
// Disabled state
|
||||
export const Disabled = {
|
||||
args: {
|
||||
type: 'text',
|
||||
label: 'Disabled Input',
|
||||
value: 'This input is disabled',
|
||||
disabled: true
|
||||
}
|
||||
};
|
||||
args: {
|
||||
type: 'text',
|
||||
label: 'Disabled Input',
|
||||
value: 'This input is disabled',
|
||||
disabled: true
|
||||
}
|
||||
}
|
||||
|
||||
// Readonly state
|
||||
export const Readonly = {
|
||||
args: {
|
||||
type: 'text',
|
||||
label: 'Readonly Input',
|
||||
value: 'This input is readonly',
|
||||
readonly: true
|
||||
}
|
||||
};
|
||||
args: {
|
||||
type: 'text',
|
||||
label: 'Readonly Input',
|
||||
value: 'This input is readonly',
|
||||
readonly: true
|
||||
}
|
||||
}
|
||||
|
||||
// With prefix icon
|
||||
export const WithPrefixIcon = {
|
||||
args: {
|
||||
type: 'email',
|
||||
label: 'Email with Icon',
|
||||
placeholder: 'you@example.com',
|
||||
prefixIcon: true
|
||||
},
|
||||
render: (args) => ({
|
||||
Component: Input,
|
||||
props: args,
|
||||
slots: {
|
||||
prefix: () => `
|
||||
args: {
|
||||
type: 'email',
|
||||
label: 'Email with Icon',
|
||||
placeholder: 'you@example.com',
|
||||
prefixIcon: true
|
||||
},
|
||||
render: (args) => ({
|
||||
Component: Input,
|
||||
props: args,
|
||||
slots: {
|
||||
prefix: () => `
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4 4H20C21.1 4 22 4.9 22 6V18C22 19.1 21.1 20 20 20H4C2.9 20 2 19.1 2 18V6C2 4.9 2.9 4 4 4Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<polyline points="22,6 12,13 2,6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
`
|
||||
}
|
||||
})
|
||||
};
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// With suffix icon
|
||||
export const WithSuffixIcon = {
|
||||
args: {
|
||||
type: 'search',
|
||||
label: 'Search with Icon',
|
||||
placeholder: 'Search...',
|
||||
suffixIcon: true
|
||||
},
|
||||
render: (args) => ({
|
||||
Component: Input,
|
||||
props: args,
|
||||
slots: {
|
||||
suffix: () => `
|
||||
args: {
|
||||
type: 'search',
|
||||
label: 'Search with Icon',
|
||||
placeholder: 'Search...',
|
||||
suffixIcon: true
|
||||
},
|
||||
render: (args) => ({
|
||||
Component: Input,
|
||||
props: args,
|
||||
slots: {
|
||||
suffix: () => `
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="11" cy="11" r="8" stroke="currentColor" stroke-width="2"/>
|
||||
<path d="M21 21L16.65 16.65" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
`
|
||||
}
|
||||
})
|
||||
};
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Color input
|
||||
export const ColorInput = {
|
||||
args: {
|
||||
type: 'color',
|
||||
label: 'Pick a Color',
|
||||
value: '#ff6b6b'
|
||||
}
|
||||
};
|
||||
args: {
|
||||
type: 'color',
|
||||
label: 'Pick a Color',
|
||||
value: '#ff6b6b'
|
||||
}
|
||||
}
|
||||
|
||||
// Date input
|
||||
export const DateInput = {
|
||||
args: {
|
||||
type: 'date',
|
||||
label: 'Select Date',
|
||||
required: true
|
||||
}
|
||||
};
|
||||
args: {
|
||||
type: 'date',
|
||||
label: 'Select Date',
|
||||
required: true
|
||||
}
|
||||
}
|
||||
|
||||
// Time input
|
||||
export const TimeInput = {
|
||||
args: {
|
||||
type: 'time',
|
||||
label: 'Select Time'
|
||||
}
|
||||
};
|
||||
args: {
|
||||
type: 'time',
|
||||
label: 'Select Time'
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,207 +1,207 @@
|
|||
import MediaInput from '$lib/components/admin/MediaInput.svelte';
|
||||
import MediaInput from '$lib/components/admin/MediaInput.svelte'
|
||||
|
||||
// Mock Media objects for testing
|
||||
const mockMedia = {
|
||||
id: '1',
|
||||
filename: 'example-image.jpg',
|
||||
originalName: 'example-image.jpg',
|
||||
mimeType: 'image/jpeg',
|
||||
size: 1024000,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
url: 'https://via.placeholder.com/300x200/0066cc/ffffff?text=Sample+Image',
|
||||
thumbnailUrl: 'https://via.placeholder.com/300x200/0066cc/ffffff?text=Sample+Image',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
};
|
||||
id: '1',
|
||||
filename: 'example-image.jpg',
|
||||
originalName: 'example-image.jpg',
|
||||
mimeType: 'image/jpeg',
|
||||
size: 1024000,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
url: 'https://via.placeholder.com/300x200/0066cc/ffffff?text=Sample+Image',
|
||||
thumbnailUrl: 'https://via.placeholder.com/300x200/0066cc/ffffff?text=Sample+Image',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
}
|
||||
|
||||
const mockMediaList = [
|
||||
mockMedia,
|
||||
{
|
||||
id: '2',
|
||||
filename: 'another-image.png',
|
||||
originalName: 'another-image.png',
|
||||
mimeType: 'image/png',
|
||||
size: 512000,
|
||||
width: 800,
|
||||
height: 600,
|
||||
url: 'https://via.placeholder.com/300x200/cc6600/ffffff?text=Image+2',
|
||||
thumbnailUrl: 'https://via.placeholder.com/300x200/cc6600/ffffff?text=Image+2',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
filename: 'third-image.jpg',
|
||||
originalName: 'third-image.jpg',
|
||||
mimeType: 'image/jpeg',
|
||||
size: 768000,
|
||||
width: 1200,
|
||||
height: 800,
|
||||
url: 'https://via.placeholder.com/300x200/009966/ffffff?text=Image+3',
|
||||
thumbnailUrl: 'https://via.placeholder.com/300x200/009966/ffffff?text=Image+3',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
}
|
||||
];
|
||||
mockMedia,
|
||||
{
|
||||
id: '2',
|
||||
filename: 'another-image.png',
|
||||
originalName: 'another-image.png',
|
||||
mimeType: 'image/png',
|
||||
size: 512000,
|
||||
width: 800,
|
||||
height: 600,
|
||||
url: 'https://via.placeholder.com/300x200/cc6600/ffffff?text=Image+2',
|
||||
thumbnailUrl: 'https://via.placeholder.com/300x200/cc6600/ffffff?text=Image+2',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
filename: 'third-image.jpg',
|
||||
originalName: 'third-image.jpg',
|
||||
mimeType: 'image/jpeg',
|
||||
size: 768000,
|
||||
width: 1200,
|
||||
height: 800,
|
||||
url: 'https://via.placeholder.com/300x200/009966/ffffff?text=Image+3',
|
||||
thumbnailUrl: 'https://via.placeholder.com/300x200/009966/ffffff?text=Image+3',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
}
|
||||
]
|
||||
|
||||
export default {
|
||||
title: 'Admin/Form Components/MediaInput',
|
||||
component: MediaInput,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
mode: {
|
||||
control: { type: 'select' },
|
||||
options: ['single', 'multiple']
|
||||
},
|
||||
fileType: {
|
||||
control: { type: 'select' },
|
||||
options: ['image', 'video', 'all']
|
||||
},
|
||||
required: {
|
||||
control: 'boolean'
|
||||
},
|
||||
label: {
|
||||
control: 'text'
|
||||
},
|
||||
placeholder: {
|
||||
control: 'text'
|
||||
},
|
||||
error: {
|
||||
control: 'text'
|
||||
}
|
||||
}
|
||||
};
|
||||
title: 'Admin/Form Components/MediaInput',
|
||||
component: MediaInput,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
mode: {
|
||||
control: { type: 'select' },
|
||||
options: ['single', 'multiple']
|
||||
},
|
||||
fileType: {
|
||||
control: { type: 'select' },
|
||||
options: ['image', 'video', 'all']
|
||||
},
|
||||
required: {
|
||||
control: 'boolean'
|
||||
},
|
||||
label: {
|
||||
control: 'text'
|
||||
},
|
||||
placeholder: {
|
||||
control: 'text'
|
||||
},
|
||||
error: {
|
||||
control: 'text'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Single media input (empty)
|
||||
export const SingleEmpty = {
|
||||
args: {
|
||||
label: 'Featured Image',
|
||||
mode: 'single',
|
||||
fileType: 'image',
|
||||
placeholder: 'No image selected',
|
||||
value: null
|
||||
}
|
||||
};
|
||||
args: {
|
||||
label: 'Featured Image',
|
||||
mode: 'single',
|
||||
fileType: 'image',
|
||||
placeholder: 'No image selected',
|
||||
value: null
|
||||
}
|
||||
}
|
||||
|
||||
// Single media input (with value)
|
||||
export const SingleWithValue = {
|
||||
args: {
|
||||
label: 'Featured Image',
|
||||
mode: 'single',
|
||||
fileType: 'image',
|
||||
value: mockMedia
|
||||
}
|
||||
};
|
||||
args: {
|
||||
label: 'Featured Image',
|
||||
mode: 'single',
|
||||
fileType: 'image',
|
||||
value: mockMedia
|
||||
}
|
||||
}
|
||||
|
||||
// Multiple media input (empty)
|
||||
export const MultipleEmpty = {
|
||||
args: {
|
||||
label: 'Gallery Images',
|
||||
mode: 'multiple',
|
||||
fileType: 'image',
|
||||
placeholder: 'No images selected',
|
||||
value: []
|
||||
}
|
||||
};
|
||||
args: {
|
||||
label: 'Gallery Images',
|
||||
mode: 'multiple',
|
||||
fileType: 'image',
|
||||
placeholder: 'No images selected',
|
||||
value: []
|
||||
}
|
||||
}
|
||||
|
||||
// Multiple media input (with values)
|
||||
export const MultipleWithValues = {
|
||||
args: {
|
||||
label: 'Gallery Images',
|
||||
mode: 'multiple',
|
||||
fileType: 'image',
|
||||
value: mockMediaList
|
||||
}
|
||||
};
|
||||
args: {
|
||||
label: 'Gallery Images',
|
||||
mode: 'multiple',
|
||||
fileType: 'image',
|
||||
value: mockMediaList
|
||||
}
|
||||
}
|
||||
|
||||
// Required field
|
||||
export const Required = {
|
||||
args: {
|
||||
label: 'Logo Image',
|
||||
mode: 'single',
|
||||
fileType: 'image',
|
||||
required: true,
|
||||
placeholder: 'Choose a logo image'
|
||||
}
|
||||
};
|
||||
args: {
|
||||
label: 'Logo Image',
|
||||
mode: 'single',
|
||||
fileType: 'image',
|
||||
required: true,
|
||||
placeholder: 'Choose a logo image'
|
||||
}
|
||||
}
|
||||
|
||||
// With error state
|
||||
export const WithError = {
|
||||
args: {
|
||||
label: 'Profile Picture',
|
||||
mode: 'single',
|
||||
fileType: 'image',
|
||||
required: true,
|
||||
error: 'Please select a profile picture'
|
||||
}
|
||||
};
|
||||
args: {
|
||||
label: 'Profile Picture',
|
||||
mode: 'single',
|
||||
fileType: 'image',
|
||||
required: true,
|
||||
error: 'Please select a profile picture'
|
||||
}
|
||||
}
|
||||
|
||||
// All file types
|
||||
export const AllFileTypes = {
|
||||
args: {
|
||||
label: 'Any Media File',
|
||||
mode: 'single',
|
||||
fileType: 'all',
|
||||
placeholder: 'Choose any media file'
|
||||
}
|
||||
};
|
||||
args: {
|
||||
label: 'Any Media File',
|
||||
mode: 'single',
|
||||
fileType: 'all',
|
||||
placeholder: 'Choose any media file'
|
||||
}
|
||||
}
|
||||
|
||||
// Video only
|
||||
export const VideoOnly = {
|
||||
args: {
|
||||
label: 'Video File',
|
||||
mode: 'single',
|
||||
fileType: 'video',
|
||||
placeholder: 'Choose a video file'
|
||||
}
|
||||
};
|
||||
args: {
|
||||
label: 'Video File',
|
||||
mode: 'single',
|
||||
fileType: 'video',
|
||||
placeholder: 'Choose a video file'
|
||||
}
|
||||
}
|
||||
|
||||
// Multiple with many files
|
||||
export const MultipleWithManyFiles = {
|
||||
args: {
|
||||
label: 'Project Assets',
|
||||
mode: 'multiple',
|
||||
fileType: 'all',
|
||||
value: [
|
||||
...mockMediaList,
|
||||
{
|
||||
id: '4',
|
||||
filename: 'video-file.mp4',
|
||||
originalName: 'video-file.mp4',
|
||||
mimeType: 'video/mp4',
|
||||
size: 5120000,
|
||||
width: null,
|
||||
height: null,
|
||||
url: 'https://via.placeholder.com/300x200/990066/ffffff?text=Video',
|
||||
thumbnailUrl: 'https://via.placeholder.com/300x200/990066/ffffff?text=Video',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
filename: 'document.pdf',
|
||||
originalName: 'document.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
size: 1024000,
|
||||
width: null,
|
||||
height: null,
|
||||
url: 'https://via.placeholder.com/300x200/666666/ffffff?text=PDF',
|
||||
thumbnailUrl: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
args: {
|
||||
label: 'Project Assets',
|
||||
mode: 'multiple',
|
||||
fileType: 'all',
|
||||
value: [
|
||||
...mockMediaList,
|
||||
{
|
||||
id: '4',
|
||||
filename: 'video-file.mp4',
|
||||
originalName: 'video-file.mp4',
|
||||
mimeType: 'video/mp4',
|
||||
size: 5120000,
|
||||
width: null,
|
||||
height: null,
|
||||
url: 'https://via.placeholder.com/300x200/990066/ffffff?text=Video',
|
||||
thumbnailUrl: 'https://via.placeholder.com/300x200/990066/ffffff?text=Video',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
filename: 'document.pdf',
|
||||
originalName: 'document.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
size: 1024000,
|
||||
width: null,
|
||||
height: null,
|
||||
url: 'https://via.placeholder.com/300x200/666666/ffffff?text=PDF',
|
||||
thumbnailUrl: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// Interactive playground
|
||||
export const Playground = {
|
||||
args: {
|
||||
label: 'Media Input',
|
||||
mode: 'single',
|
||||
fileType: 'image',
|
||||
required: false,
|
||||
placeholder: 'Select a file'
|
||||
}
|
||||
};
|
||||
args: {
|
||||
label: 'Media Input',
|
||||
mode: 'single',
|
||||
fileType: 'image',
|
||||
required: false,
|
||||
placeholder: 'Select a file'
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,30 +1,30 @@
|
|||
.storybook-button {
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
border: 0;
|
||||
border-radius: 3em;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
border: 0;
|
||||
border-radius: 3em;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
}
|
||||
.storybook-button--primary {
|
||||
background-color: #555ab9;
|
||||
color: white;
|
||||
background-color: #555ab9;
|
||||
color: white;
|
||||
}
|
||||
.storybook-button--secondary {
|
||||
box-shadow: rgba(0, 0, 0, 0.15) 0px 0px 0px 1px inset;
|
||||
background-color: transparent;
|
||||
color: #333;
|
||||
box-shadow: rgba(0, 0, 0, 0.15) 0px 0px 0px 1px inset;
|
||||
background-color: transparent;
|
||||
color: #333;
|
||||
}
|
||||
.storybook-button--small {
|
||||
padding: 10px 16px;
|
||||
font-size: 12px;
|
||||
padding: 10px 16px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.storybook-button--medium {
|
||||
padding: 11px 20px;
|
||||
font-size: 14px;
|
||||
padding: 11px 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.storybook-button--large {
|
||||
padding: 12px 24px;
|
||||
font-size: 16px;
|
||||
padding: 12px 24px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue