tbh i dont know whats going on anymore
This commit is contained in:
parent
baa030ac1c
commit
c7b4f57ab0
164 changed files with 22363 additions and 11428 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -24,3 +24,6 @@ vite.config.ts.timestamp-*
|
|||
|
||||
# Local uploads (for development)
|
||||
/static/local-uploads
|
||||
|
||||
*storybook.log
|
||||
storybook-static
|
||||
|
|
|
|||
46
.storybook/main.ts
Normal file
46
.storybook/main.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
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: `
|
||||
@import './src/assets/styles/variables.scss';
|
||||
@import './src/assets/styles/fonts.scss';
|
||||
@import './src/assets/styles/themes.scss';
|
||||
`,
|
||||
api: 'modern-compiler'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
42
.storybook/preview.ts
Normal file
42
.storybook/preview.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
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' }
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default preview;
|
||||
|
|
@ -34,6 +34,11 @@ npm run preview
|
|||
|
||||
This is a SvelteKit personal portfolio site for @jedmund that integrates with multiple external APIs to display real-time data about music listening habits and gaming activity.
|
||||
|
||||
We are using Svelte 5 in Runes mode, so make sure to only write solutions that will work with that newer syntax.
|
||||
|
||||
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,3 +1,6 @@
|
|||
// For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format
|
||||
import storybook from "eslint-plugin-storybook";
|
||||
|
||||
import js from '@eslint/js'
|
||||
import ts from 'typescript-eslint'
|
||||
import svelte from 'eslint-plugin-svelte'
|
||||
|
|
@ -6,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,
|
||||
|
|
@ -19,7 +22,7 @@ export default [
|
|||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
{
|
||||
files: ['**/*.svelte'],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
|
|
@ -27,7 +30,8 @@ export default [
|
|||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
{
|
||||
ignores: ['build/', '.svelte-kit/', 'dist/']
|
||||
}
|
||||
]
|
||||
},
|
||||
...storybook.configs["flat/recommended"]
|
||||
];
|
||||
|
|
|
|||
16884
package-lock.json
generated
16884
package-lock.json
generated
File diff suppressed because it is too large
Load diff
220
package.json
220
package.json
|
|
@ -1,108 +1,116 @@
|
|||
{
|
||||
"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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@musicorum/lastfm": "github:jedmund/lastfm",
|
||||
"@poppanator/sveltekit-svg": "^5.0.0-svelte5.4",
|
||||
"@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-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",
|
||||
"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",
|
||||
"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",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
/*
|
||||
Warnings:
|
||||
|
||||
- Added the required column `updatedAt` to the `Media` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "Media" ADD COLUMN "altText" TEXT,
|
||||
ADD COLUMN "description" TEXT,
|
||||
ADD COLUMN "originalName" VARCHAR(255),
|
||||
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
||||
|
||||
-- Set originalName to filename for existing records where it's null
|
||||
UPDATE "Media" SET "originalName" = "filename" WHERE "originalName" IS NULL;
|
||||
|
|
@ -126,12 +126,16 @@ model Photo {
|
|||
model Media {
|
||||
id Int @id @default(autoincrement())
|
||||
filename String @db.VarChar(255)
|
||||
originalName String? @db.VarChar(255) // Original filename from user (optional for backward compatibility)
|
||||
mimeType String @db.VarChar(100)
|
||||
size Int
|
||||
url String @db.Text
|
||||
thumbnailUrl String? @db.Text
|
||||
width Int?
|
||||
height Int?
|
||||
usedIn Json @default("[]") // Track where media is used
|
||||
altText String? @db.Text // Alt text for accessibility
|
||||
description String? @db.Text // Optional description
|
||||
usedIn Json @default("[]") // Track where media is used
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
6
src/assets/icons/dashboard.svg
Normal file
6
src/assets/icons/dashboard.svg
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="3" y="3" width="6" height="6" stroke="currentColor" stroke-width="1.5" fill="none" rx="1" />
|
||||
<rect x="11" y="3" width="6" height="6" stroke="currentColor" stroke-width="1.5" fill="none" rx="1" />
|
||||
<rect x="3" y="11" width="6" height="6" stroke="currentColor" stroke-width="1.5" fill="none" rx="1" />
|
||||
<rect x="11" y="11" width="6" height="6" stroke="currentColor" stroke-width="1.5" fill="none" rx="1" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 518 B |
1
src/assets/icons/refresh.svg
Normal file
1
src/assets/icons/refresh.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 118.04 122.88"><path d="M16.08,59.26A8,8,0,0,1,0,59.26a59,59,0,0,1,97.13-45V8a8,8,0,1,1,16.08,0V33.35a8,8,0,0,1-8,8L80.82,43.62a8,8,0,1,1-1.44-15.95l8-.73A43,43,0,0,0,16.08,59.26Zm22.77,19.6a8,8,0,0,1,1.44,16l-10.08.91A42.95,42.95,0,0,0,102,63.86a8,8,0,0,1,16.08,0A59,59,0,0,1,22.3,110v4.18a8,8,0,0,1-16.08,0V89.14h0a8,8,0,0,1,7.29-8l25.31-2.3Z"/></svg>
|
||||
|
After Width: | Height: | Size: 439 B |
|
|
@ -0,0 +1,22 @@
|
|||
// Global font family setting
|
||||
// This applies the cstd font to all elements by default
|
||||
* {
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
|
||||
// Global body styles
|
||||
body {
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
font-weight: 400;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
// Heading font weights
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
// Button and input font inheritance
|
||||
button, input, textarea, select {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
|
@ -69,7 +69,9 @@ $grey-95: #f5f5f5;
|
|||
$grey-90: #f7f7f7;
|
||||
$grey-85: #ebebeb;
|
||||
$grey-80: #e8e8e8;
|
||||
$grey-70: #dfdfdf;
|
||||
$grey-60: #cccccc;
|
||||
$grey-5: #f9f9f9;
|
||||
$grey-50: #b2b2b2;
|
||||
$grey-40: #999999;
|
||||
$grey-30: #808080;
|
||||
|
|
@ -79,9 +81,26 @@ $grey-00: #333333;
|
|||
|
||||
$red-80: #ff6a54;
|
||||
$red-60: #e33d3d;
|
||||
$red-50: #d33;
|
||||
$red-40: #d31919;
|
||||
$red-00: #3d0c0c;
|
||||
|
||||
$blue-60: #2e8bc0;
|
||||
$blue-50: #1482c1;
|
||||
$blue-40: #126fa8;
|
||||
$blue-20: #0f5d8f;
|
||||
$blue-10: #e6f3ff;
|
||||
|
||||
$yellow-90: #fff9e6;
|
||||
$yellow-80: #ffeb99;
|
||||
$yellow-70: #ffdd4d;
|
||||
$yellow-60: #ffcc00;
|
||||
$yellow-50: #f5c500;
|
||||
$yellow-40: #e6b800;
|
||||
$yellow-30: #cc9900;
|
||||
$yellow-20: #996600;
|
||||
$yellow-10: #664400;
|
||||
|
||||
$salmon-pink: #ffbdb3; // Desaturated salmon pink for hover states
|
||||
|
||||
$bg-color: #e8e8e8;
|
||||
|
|
|
|||
45
src/lib/admin-auth.ts
Normal file
45
src/lib/admin-auth.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
// Simple admin authentication helper for client-side use
|
||||
// In a real application, this would use proper JWT tokens or session cookies
|
||||
|
||||
let adminCredentials: string | null = null
|
||||
|
||||
// Initialize auth (call this when the admin logs in)
|
||||
export function setAdminAuth(username: string, password: string) {
|
||||
adminCredentials = btoa(`${username}:${password}`)
|
||||
}
|
||||
|
||||
// Get auth headers for API requests
|
||||
export function getAuthHeaders(): HeadersInit {
|
||||
if (!adminCredentials) {
|
||||
// For development, use default credentials
|
||||
// In production, this should redirect to login
|
||||
adminCredentials = btoa('admin:localdev')
|
||||
}
|
||||
|
||||
return {
|
||||
'Authorization': `Basic ${adminCredentials}`
|
||||
}
|
||||
}
|
||||
|
||||
// Check if user is authenticated (basic check)
|
||||
export function isAuthenticated(): boolean {
|
||||
return adminCredentials !== null
|
||||
}
|
||||
|
||||
// Clear auth (logout)
|
||||
export function clearAuth() {
|
||||
adminCredentials = null
|
||||
}
|
||||
|
||||
// Make authenticated API request
|
||||
export async function authenticatedFetch(url: string, options: RequestInit = {}): Promise<Response> {
|
||||
const headers = {
|
||||
...getAuthHeaders(),
|
||||
...options.headers
|
||||
}
|
||||
|
||||
return fetch(url, {
|
||||
...options,
|
||||
headers
|
||||
})
|
||||
}
|
||||
|
|
@ -1,37 +1,35 @@
|
|||
<script>
|
||||
// What if we have a headphones avatar that is head bopping if the last scrobble was < 5 mins ago
|
||||
// We can do a thought bubble-y thing with the album art that takes you to the album section of the page
|
||||
import { onMount } from 'svelte'
|
||||
import { onMount, onDestroy } from 'svelte'
|
||||
import { spring } from 'svelte/motion'
|
||||
|
||||
let isHovering = $state(false)
|
||||
let isBlinking = $state(false)
|
||||
let isHovering = false
|
||||
let isBlinking = false
|
||||
|
||||
const scale = spring(1, {
|
||||
stiffness: 0.1,
|
||||
damping: 0.125
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if (isHovering) {
|
||||
scale.set(1.25)
|
||||
} else {
|
||||
scale.set(1)
|
||||
}
|
||||
})
|
||||
function handleMouseEnter() {
|
||||
isHovering = true
|
||||
scale.set(1.25)
|
||||
}
|
||||
|
||||
function handleMouseLeave() {
|
||||
isHovering = false
|
||||
scale.set(1)
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function setBlinkState(state) {
|
||||
isBlinking = state
|
||||
}
|
||||
|
||||
async function singleBlink(duration) {
|
||||
setBlinkState(true)
|
||||
isBlinking = true
|
||||
await sleep(duration)
|
||||
setBlinkState(false)
|
||||
isBlinking = false
|
||||
}
|
||||
|
||||
async function doubleBlink() {
|
||||
|
|
@ -48,25 +46,27 @@
|
|||
}
|
||||
}
|
||||
|
||||
function startBlinking() {
|
||||
const blinkInterval = setInterval(() => {
|
||||
let blinkInterval
|
||||
|
||||
onMount(() => {
|
||||
blinkInterval = setInterval(() => {
|
||||
if (!isHovering) {
|
||||
blink()
|
||||
}
|
||||
}, 4000)
|
||||
|
||||
return () => clearInterval(blinkInterval)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
return startBlinking()
|
||||
return () => {
|
||||
if (blinkInterval) {
|
||||
clearInterval(blinkInterval)
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="face-container"
|
||||
on:mouseenter={() => (isHovering = true)}
|
||||
on:mouseleave={() => (isHovering = false)}
|
||||
onmouseenter={handleMouseEnter}
|
||||
onmouseleave={handleMouseLeave}
|
||||
style="transform: scale({$scale})"
|
||||
>
|
||||
<svg
|
||||
|
|
|
|||
21
src/lib/components/AvatarSimple.svelte
Normal file
21
src/lib/components/AvatarSimple.svelte
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<script>
|
||||
import jedmundIcon from '$illos/jedmund.svg?raw'
|
||||
</script>
|
||||
|
||||
<div class="avatar-simple">
|
||||
{@html jedmundIcon}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.avatar-simple {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
:global(svg) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: $avatar-radius;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -109,7 +109,6 @@
|
|||
width: 100%;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font-family: 'cstd', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
transition: border-color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
|
|
@ -163,7 +162,6 @@
|
|||
gap: $unit;
|
||||
font-size: 0.875rem;
|
||||
color: $grey-40;
|
||||
font-family: 'cstd', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
.favicon {
|
||||
|
|
@ -184,7 +182,6 @@
|
|||
font-weight: 600;
|
||||
color: $grey-00;
|
||||
line-height: 1.3;
|
||||
font-family: 'cstd', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
|
|
@ -196,7 +193,6 @@
|
|||
font-size: 0.875rem;
|
||||
color: $grey-40;
|
||||
line-height: 1.4;
|
||||
font-family: 'cstd', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
|
|
|
|||
|
|
@ -32,7 +32,6 @@
|
|||
color: $grey-20; // #666
|
||||
font-size: 1rem;
|
||||
font-weight: 400;
|
||||
font-family: 'cstd', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
:global(svg) {
|
||||
|
|
|
|||
|
|
@ -12,7 +12,15 @@
|
|||
index?: number
|
||||
}
|
||||
|
||||
let { logoUrl, backgroundColor, name, slug, description, highlightColor, index = 0 }: Props = $props()
|
||||
let {
|
||||
logoUrl,
|
||||
backgroundColor,
|
||||
name,
|
||||
slug,
|
||||
description,
|
||||
highlightColor,
|
||||
index = 0
|
||||
}: Props = $props()
|
||||
|
||||
const isEven = $derived(index % 2 === 0)
|
||||
|
||||
|
|
@ -131,7 +139,7 @@
|
|||
function handleMouseLeave() {
|
||||
isHovering = false
|
||||
transform = 'perspective(1000px) rotateX(0) rotateY(0) scale3d(1, 1, 1)'
|
||||
|
||||
|
||||
// Reset logo position
|
||||
velocity = { x: 0, y: 0 }
|
||||
position = { x: 0, y: 0 }
|
||||
|
|
@ -141,7 +149,7 @@
|
|||
animationFrame = 0
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function handleClick() {
|
||||
goto(`/work/${slug}`)
|
||||
}
|
||||
|
|
@ -161,11 +169,7 @@
|
|||
>
|
||||
<div class="project-logo" style="background-color: {backgroundColor}">
|
||||
{#if svgContent}
|
||||
<div
|
||||
bind:this={logoElement}
|
||||
class="logo-svg"
|
||||
style="transform: {logoTransform}"
|
||||
>
|
||||
<div bind:this={logoElement} class="logo-svg" style="transform: {logoTransform}">
|
||||
{@html svgContent}
|
||||
</div>
|
||||
{:else if logoUrl}
|
||||
|
|
@ -260,4 +264,4 @@
|
|||
height: 60px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -30,14 +30,14 @@
|
|||
{/if}
|
||||
{#each projects as project, index}
|
||||
<li>
|
||||
<ProjectItem
|
||||
<ProjectItem
|
||||
logoUrl={project.logoUrl}
|
||||
backgroundColor={project.backgroundColor || '#f7f7f7'}
|
||||
name={project.title}
|
||||
slug={project.slug}
|
||||
description={project.description || ''}
|
||||
highlightColor={project.highlightColor || '#333'}
|
||||
{index}
|
||||
{index}
|
||||
/>
|
||||
</li>
|
||||
{/each}
|
||||
|
|
@ -91,6 +91,5 @@
|
|||
padding: $unit-3x;
|
||||
text-align: center;
|
||||
color: $grey-40;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -162,7 +162,6 @@
|
|||
text-decoration: none;
|
||||
font-size: 1rem;
|
||||
font-weight: 400;
|
||||
font-family: 'cstd', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
transition:
|
||||
|
|
|
|||
133
src/lib/components/SmartImage.svelte
Normal file
133
src/lib/components/SmartImage.svelte
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
<script lang="ts">
|
||||
import type { Media } from '@prisma/client'
|
||||
import { browser } from '$app/environment'
|
||||
|
||||
interface Props {
|
||||
media: Media
|
||||
alt?: string
|
||||
class?: string
|
||||
containerWidth?: number // If known, use this for smart sizing
|
||||
loading?: 'lazy' | 'eager'
|
||||
aspectRatio?: string
|
||||
sizes?: string // For responsive images
|
||||
}
|
||||
|
||||
let {
|
||||
media,
|
||||
alt = media.altText || media.filename || '',
|
||||
class: className = '',
|
||||
containerWidth,
|
||||
loading = 'lazy',
|
||||
aspectRatio,
|
||||
sizes = '(max-width: 768px) 100vw, (max-width: 1200px) 80vw, 1200px'
|
||||
}: Props = $props()
|
||||
|
||||
let imgElement: HTMLImageElement
|
||||
let actualContainerWidth = $state<number | undefined>(containerWidth)
|
||||
let imageUrl = $state('')
|
||||
let srcSet = $state('')
|
||||
|
||||
// Update image URL when container width changes
|
||||
$effect(() => {
|
||||
imageUrl = getImageUrl()
|
||||
srcSet = getSrcSet()
|
||||
})
|
||||
|
||||
// Detect container width if not provided
|
||||
$effect(() => {
|
||||
if (browser && !containerWidth && imgElement?.parentElement) {
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
actualContainerWidth = entry.contentRect.width
|
||||
}
|
||||
})
|
||||
|
||||
resizeObserver.observe(imgElement.parentElement)
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Smart image URL selection
|
||||
function getImageUrl(): string {
|
||||
if (!media.url) return ''
|
||||
|
||||
// SVG files should always use the original URL (they're vector, no thumbnails needed)
|
||||
if (media.mimeType === 'image/svg+xml' || media.url.endsWith('.svg')) {
|
||||
return media.url
|
||||
}
|
||||
|
||||
// For local development, use what we have
|
||||
if (media.url.startsWith('/local-uploads')) {
|
||||
// For larger containers, prefer original over thumbnail
|
||||
if (actualContainerWidth && actualContainerWidth > 400) {
|
||||
return media.url // Original image
|
||||
}
|
||||
return media.thumbnailUrl || media.url
|
||||
}
|
||||
|
||||
// For Cloudinary images, we could implement smart URL generation here
|
||||
// For now, use the same logic as local
|
||||
if (actualContainerWidth && actualContainerWidth > 400) {
|
||||
return media.url
|
||||
}
|
||||
return media.thumbnailUrl || media.url
|
||||
}
|
||||
|
||||
// Generate responsive srcset for better performance
|
||||
function getSrcSet(): string {
|
||||
// SVG files don't need srcset (they're vector and scale infinitely)
|
||||
if (media.mimeType === 'image/svg+xml' || media.url.endsWith('.svg')) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (!media.url || media.url.startsWith('/local-uploads')) {
|
||||
// For local images, just provide the main options
|
||||
const sources = []
|
||||
if (media.thumbnailUrl) {
|
||||
sources.push(`${media.thumbnailUrl} 800w`)
|
||||
}
|
||||
if (media.url) {
|
||||
sources.push(`${media.url} ${media.width || 1920}w`)
|
||||
}
|
||||
return sources.join(', ')
|
||||
}
|
||||
|
||||
// For Cloudinary, we could generate multiple sizes
|
||||
// This is a placeholder for future implementation
|
||||
return ''
|
||||
}
|
||||
|
||||
// Compute styles
|
||||
function getImageStyles(): string {
|
||||
let styles = ''
|
||||
|
||||
if (aspectRatio) {
|
||||
styles += `aspect-ratio: ${aspectRatio.replace(':', '/')};`
|
||||
}
|
||||
|
||||
return styles
|
||||
}
|
||||
</script>
|
||||
|
||||
<img
|
||||
bind:this={imgElement}
|
||||
src={imageUrl}
|
||||
{alt}
|
||||
class={className}
|
||||
style={getImageStyles()}
|
||||
{loading}
|
||||
srcset={srcSet || undefined}
|
||||
{sizes}
|
||||
width={media.width || undefined}
|
||||
height={media.height || undefined}
|
||||
/>
|
||||
|
||||
<style>
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,20 +1,24 @@
|
|||
<script lang="ts">
|
||||
import { page } from '$app/stores'
|
||||
import Avatar from '$lib/components/Avatar.svelte'
|
||||
import AvatarSimple from '$lib/components/AvatarSimple.svelte'
|
||||
import DashboardIcon from '$icons/dashboard.svg?component'
|
||||
import WorkIcon from '$icons/work.svg?component'
|
||||
import UniverseIcon from '$icons/universe.svg?component'
|
||||
import PhotosIcon from '$icons/photos.svg?component'
|
||||
|
||||
const currentPath = $derived($page.url.pathname)
|
||||
|
||||
interface NavItem {
|
||||
text: string
|
||||
href: string
|
||||
icon: string
|
||||
icon: any
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ text: 'Dashboard', href: '/admin', icon: 'dashboard' },
|
||||
{ text: 'Projects', href: '/admin/projects', icon: 'work' },
|
||||
{ text: 'Universe', href: '/admin/posts', icon: 'universe' },
|
||||
{ text: 'Media', href: '/admin/media', icon: 'photos' }
|
||||
{ text: 'Dashboard', href: '/admin', icon: DashboardIcon },
|
||||
{ text: 'Projects', href: '/admin/projects', icon: WorkIcon },
|
||||
{ text: 'Universe', href: '/admin/posts', icon: UniverseIcon },
|
||||
{ text: 'Media', href: '/admin/media', icon: PhotosIcon }
|
||||
]
|
||||
|
||||
// Calculate active index based on current path
|
||||
|
|
@ -36,7 +40,7 @@
|
|||
<div class="nav-content">
|
||||
<a href="/" class="nav-brand">
|
||||
<div class="brand-logo">
|
||||
<Avatar />
|
||||
<AvatarSimple />
|
||||
</div>
|
||||
<span class="brand-text">Back to jedmund.com</span>
|
||||
</a>
|
||||
|
|
@ -44,108 +48,7 @@
|
|||
<div class="nav-links">
|
||||
{#each navItems as item, index}
|
||||
<a href={item.href} class="nav-link" class:active={index === activeIndex}>
|
||||
<svg class="nav-icon" width="20" height="20" viewBox="0 0 20 20">
|
||||
{#if item.icon === 'dashboard'}
|
||||
<rect
|
||||
x="3"
|
||||
y="3"
|
||||
width="6"
|
||||
height="6"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
fill="none"
|
||||
rx="1"
|
||||
/>
|
||||
<rect
|
||||
x="11"
|
||||
y="3"
|
||||
width="6"
|
||||
height="6"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
fill="none"
|
||||
rx="1"
|
||||
/>
|
||||
<rect
|
||||
x="3"
|
||||
y="11"
|
||||
width="6"
|
||||
height="6"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
fill="none"
|
||||
rx="1"
|
||||
/>
|
||||
<rect
|
||||
x="11"
|
||||
y="11"
|
||||
width="6"
|
||||
height="6"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
fill="none"
|
||||
rx="1"
|
||||
/>
|
||||
{:else if item.icon === 'work'}
|
||||
<rect
|
||||
x="2"
|
||||
y="4"
|
||||
width="16"
|
||||
height="12"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
fill="none"
|
||||
rx="2"
|
||||
/>
|
||||
<path
|
||||
d="M8 4V3C8 2.44772 8.44772 2 9 2H11C11.5523 2 12 2.44772 12 3V4"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
<line x1="2" y1="9" x2="18" y2="9" stroke="currentColor" stroke-width="1.5" />
|
||||
{:else if item.icon === 'universe'}
|
||||
<circle
|
||||
cx="10"
|
||||
cy="10"
|
||||
r="8"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
fill="none"
|
||||
/>
|
||||
<circle cx="10" cy="10" r="2" fill="currentColor" />
|
||||
<circle cx="10" cy="4" r="1" fill="currentColor" />
|
||||
<circle cx="16" cy="10" r="1" fill="currentColor" />
|
||||
<circle cx="10" cy="16" r="1" fill="currentColor" />
|
||||
<circle cx="4" cy="10" r="1" fill="currentColor" />
|
||||
{:else if item.icon === 'photos'}
|
||||
<rect
|
||||
x="3"
|
||||
y="5"
|
||||
width="14"
|
||||
height="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
fill="none"
|
||||
rx="1"
|
||||
/>
|
||||
<circle
|
||||
cx="7"
|
||||
cy="9"
|
||||
r="1.5"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
d="M3 12L7 8L10 11L13 8L17 12"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
{/if}
|
||||
</svg>
|
||||
<item.icon class="nav-icon" />
|
||||
<span class="nav-text">{item.text}</span>
|
||||
</a>
|
||||
{/each}
|
||||
|
|
@ -168,8 +71,8 @@
|
|||
top: 0;
|
||||
z-index: 100;
|
||||
width: 100%;
|
||||
background-color: $grey-60;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: $grey-90;
|
||||
border-bottom: 1px solid $grey-70;
|
||||
}
|
||||
|
||||
.nav-container {
|
||||
|
|
@ -222,7 +125,6 @@
|
|||
gap: $unit;
|
||||
text-decoration: none;
|
||||
color: $grey-30;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
font-weight: 400;
|
||||
font-size: 0.925rem;
|
||||
transition: color 0.2s ease;
|
||||
|
|
@ -281,7 +183,6 @@
|
|||
text-decoration: none;
|
||||
font-size: 0.925rem;
|
||||
font-weight: 500;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
color: $grey-30;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
|
|
|
|||
|
|
@ -35,7 +35,6 @@
|
|||
border: none;
|
||||
border-radius: 50px;
|
||||
font-size: 0.925rem;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
color: $grey-40;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
|
|
|||
|
|
@ -175,7 +175,6 @@
|
|||
text-decoration: none;
|
||||
font-size: 1rem;
|
||||
font-weight: 400;
|
||||
font-family: 'cstd', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
color: $grey-20;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
|
|
@ -251,7 +250,6 @@
|
|||
border: none;
|
||||
text-align: left;
|
||||
font-size: 0.925rem;
|
||||
font-family: 'cstd', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
color: $grey-20;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
|
|
|
|||
344
src/lib/components/admin/Button.svelte
Normal file
344
src/lib/components/admin/Button.svelte
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
<script lang="ts">
|
||||
import type { HTMLButtonAttributes } from 'svelte/elements'
|
||||
|
||||
interface Props extends HTMLButtonAttributes {
|
||||
variant?: 'primary' | 'secondary' | 'danger' | 'ghost' | 'text' | 'overlay'
|
||||
size?: 'small' | 'medium' | 'large' | 'icon'
|
||||
iconOnly?: boolean
|
||||
iconPosition?: 'left' | 'right'
|
||||
pill?: boolean
|
||||
fullWidth?: boolean
|
||||
loading?: boolean
|
||||
active?: boolean
|
||||
class?: string
|
||||
}
|
||||
|
||||
let {
|
||||
variant = 'primary',
|
||||
size = 'medium',
|
||||
iconOnly = false,
|
||||
iconPosition = 'left',
|
||||
pill = true,
|
||||
fullWidth = false,
|
||||
loading = false,
|
||||
active = false,
|
||||
disabled = false,
|
||||
type = 'button',
|
||||
class: className = '',
|
||||
children,
|
||||
onclick,
|
||||
...restProps
|
||||
}: Props = $props()
|
||||
|
||||
// Compute button classes
|
||||
const buttonClass = $derived(() => {
|
||||
const classes = ['btn']
|
||||
|
||||
// Variant
|
||||
classes.push(`btn-${variant}`)
|
||||
|
||||
// Size
|
||||
if (!iconOnly) {
|
||||
classes.push(`btn-${size}`)
|
||||
} else {
|
||||
classes.push('btn-icon')
|
||||
classes.push(`btn-icon-${size}`)
|
||||
}
|
||||
|
||||
// States
|
||||
if (active) classes.push('active')
|
||||
if (loading) classes.push('loading')
|
||||
if (fullWidth) classes.push('full-width')
|
||||
if (!pill && !iconOnly) classes.push('btn-square')
|
||||
|
||||
// Custom class
|
||||
if (className) classes.push(className)
|
||||
|
||||
return classes.join(' ')
|
||||
})
|
||||
|
||||
// Handle icon slot positioning
|
||||
const hasIcon = $derived(!!$$slots.icon)
|
||||
const hasDefaultSlot = $derived(!!$$slots.default)
|
||||
const showSpinner = $derived(loading && !iconOnly)
|
||||
</script>
|
||||
|
||||
<button
|
||||
class={buttonClass()}
|
||||
{type}
|
||||
disabled={disabled || loading}
|
||||
{onclick}
|
||||
{...restProps}
|
||||
>
|
||||
{#if showSpinner}
|
||||
<svg class="btn-spinner" width="16" height="16" viewBox="0 0 16 16">
|
||||
<circle
|
||||
cx="8"
|
||||
cy="8"
|
||||
r="6"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
fill="none"
|
||||
stroke-dasharray="25"
|
||||
stroke-dashoffset="25"
|
||||
stroke-linecap="round"
|
||||
>
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
type="rotate"
|
||||
from="0 8 8"
|
||||
to="360 8 8"
|
||||
dur="1s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</circle>
|
||||
</svg>
|
||||
{/if}
|
||||
|
||||
{#if hasIcon && iconPosition === 'left' && !iconOnly}
|
||||
<span class="btn-icon-wrapper">
|
||||
<slot name="icon" />
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if hasDefaultSlot && !iconOnly}
|
||||
<span class="btn-label">
|
||||
<slot />
|
||||
</span>
|
||||
{:else if iconOnly && hasIcon}
|
||||
<slot name="icon" />
|
||||
{/if}
|
||||
|
||||
{#if hasIcon && iconPosition === 'right' && !iconOnly}
|
||||
<span class="btn-icon-wrapper">
|
||||
<slot name="icon" />
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<style lang="scss">
|
||||
@import '$styles/variables.scss';
|
||||
|
||||
// Base button styles
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: $unit;
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
outline: none;
|
||||
position: relative;
|
||||
white-space: nowrap;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&.loading {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
&.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
// Size variations
|
||||
.btn-small {
|
||||
padding: $unit calc($unit * 1.5);
|
||||
font-size: 13px;
|
||||
border-radius: 20px;
|
||||
min-height: 28px;
|
||||
}
|
||||
|
||||
.btn-medium {
|
||||
padding: $unit $unit-2x;
|
||||
font-size: 14px;
|
||||
border-radius: 24px;
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.btn-large {
|
||||
padding: calc($unit * 1.5) $unit-3x;
|
||||
font-size: 15px;
|
||||
border-radius: 28px;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
// Square corners variant
|
||||
.btn-square {
|
||||
&.btn-small {
|
||||
border-radius: 6px;
|
||||
}
|
||||
&.btn-medium {
|
||||
border-radius: 8px;
|
||||
}
|
||||
&.btn-large {
|
||||
border-radius: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
// Icon-only button styles
|
||||
.btn-icon {
|
||||
padding: 0;
|
||||
border-radius: 8px;
|
||||
|
||||
&.btn-icon-small {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
&.btn-icon-medium {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
&.btn-icon-large {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
&.btn-icon-icon {
|
||||
// For circular icon buttons
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 17px;
|
||||
}
|
||||
}
|
||||
|
||||
// Variant styles
|
||||
.btn-primary {
|
||||
background-color: $red-60;
|
||||
color: white;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: $red-80;
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
background-color: $red-40;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: $grey-10;
|
||||
color: $grey-80;
|
||||
border: 1px solid $grey-20;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: $grey-20;
|
||||
border-color: $grey-30;
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
background-color: $grey-30;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: $yellow-60;
|
||||
color: $yellow-10;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: $yellow-50;
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
background-color: $yellow-40;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background-color: transparent;
|
||||
color: $grey-20;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: $grey-5;
|
||||
color: $grey-00;
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
background-color: $grey-10;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: $grey-10;
|
||||
color: $grey-00;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
background: none;
|
||||
color: $grey-40;
|
||||
padding: $unit;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: $grey-20;
|
||||
background-color: $grey-5;
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
color: $grey-00;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-overlay {
|
||||
background-color: white;
|
||||
color: $grey-20;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: $grey-5;
|
||||
color: $grey-00;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
background-color: $grey-10;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
// Icon wrapper
|
||||
.btn-icon-wrapper {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// Loading spinner
|
||||
.btn-spinner {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: currentColor;
|
||||
}
|
||||
|
||||
// Label wrapper
|
||||
.btn-label {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
// Special states
|
||||
.btn.active {
|
||||
&.btn-ghost {
|
||||
background-color: rgba($blue-50, 0.1);
|
||||
color: $blue-50;
|
||||
}
|
||||
}
|
||||
|
||||
// Icon color inheritance
|
||||
:global(.btn svg) {
|
||||
color: currentColor;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import Button from './Button.svelte'
|
||||
|
||||
interface Props {
|
||||
title?: string
|
||||
|
|
@ -38,12 +39,12 @@
|
|||
<h2>{title}</h2>
|
||||
<p>{message}</p>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" onclick={handleCancel}>
|
||||
<Button variant="secondary" onclick={handleCancel}>
|
||||
{cancelText}
|
||||
</button>
|
||||
<button class="btn btn-danger" onclick={handleConfirm}>
|
||||
</Button>
|
||||
<Button variant="danger" onclick={handleConfirm}>
|
||||
{confirmText}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -75,15 +76,13 @@
|
|||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: $grey-10;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 $unit-4x;
|
||||
color: $grey-20;
|
||||
line-height: 1.5;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
|
|
@ -91,33 +90,4 @@
|
|||
gap: $unit-2x;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: $unit-2x $unit-3x;
|
||||
border-radius: 50px;
|
||||
text-decoration: none;
|
||||
font-size: 0.925rem;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
transition: all 0.2s ease;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
|
||||
&.btn-secondary {
|
||||
background-color: $grey-85;
|
||||
color: $grey-20;
|
||||
|
||||
&:hover {
|
||||
background-color: $grey-80;
|
||||
}
|
||||
}
|
||||
|
||||
&.btn-danger {
|
||||
background-color: $red-60;
|
||||
color: white;
|
||||
|
||||
&:hover {
|
||||
background-color: $red-40;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
autofocus?: boolean
|
||||
class?: string
|
||||
showToolbar?: boolean
|
||||
simpleMode?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
|
|
@ -25,7 +26,8 @@
|
|||
minHeight = 400,
|
||||
autofocus = false,
|
||||
class: className = '',
|
||||
showToolbar = true
|
||||
showToolbar = true,
|
||||
simpleMode = false
|
||||
}: Props = $props()
|
||||
|
||||
let editor = $state<Editor | undefined>()
|
||||
|
|
@ -65,7 +67,12 @@
|
|||
// Focus on mount if requested
|
||||
$effect(() => {
|
||||
if (editor && autofocus) {
|
||||
editor.commands.focus()
|
||||
// Only focus once on initial mount
|
||||
const timer = setTimeout(() => {
|
||||
editor.commands.focus()
|
||||
}, 100)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
|
@ -77,11 +84,11 @@
|
|||
content={data}
|
||||
{onUpdate}
|
||||
editable={!readOnly}
|
||||
{showToolbar}
|
||||
showToolbar={!simpleMode && showToolbar}
|
||||
{placeholder}
|
||||
showSlashCommands={true}
|
||||
showLinkBubbleMenu={true}
|
||||
showTableBubbleMenu={true}
|
||||
showSlashCommands={!simpleMode}
|
||||
showLinkBubbleMenu={!simpleMode}
|
||||
showTableBubbleMenu={false}
|
||||
class="editor-content"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -171,7 +178,6 @@
|
|||
}
|
||||
|
||||
:global(.edra .ProseMirror) {
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
color: $grey-10;
|
||||
|
|
@ -180,7 +186,6 @@
|
|||
}
|
||||
|
||||
:global(.edra .ProseMirror h1) {
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
margin: $unit-3x 0 $unit-2x;
|
||||
|
|
@ -188,7 +193,6 @@
|
|||
}
|
||||
|
||||
:global(.edra .ProseMirror h2) {
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin: $unit-3x 0 $unit-2x;
|
||||
|
|
@ -196,7 +200,6 @@
|
|||
}
|
||||
|
||||
:global(.edra .ProseMirror h3) {
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin: $unit-3x 0 $unit-2x;
|
||||
|
|
@ -335,7 +338,6 @@
|
|||
:global(.edra-media-placeholder-text) {
|
||||
font-size: 1rem;
|
||||
color: $grey-30;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
|
||||
// Image container styles
|
||||
|
|
@ -372,7 +374,6 @@
|
|||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
color: $grey-30;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
background: $grey-95;
|
||||
|
||||
&:focus {
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@
|
|||
// Filter out unwanted commands
|
||||
const getFilteredCommands = () => {
|
||||
const filtered = { ...commands }
|
||||
|
||||
|
||||
// Remove these groups entirely
|
||||
delete filtered['undo-redo']
|
||||
delete filtered['headings'] // In text style dropdown
|
||||
|
|
@ -77,60 +77,60 @@
|
|||
delete filtered['alignment'] // Not needed
|
||||
delete filtered['table'] // Not needed
|
||||
delete filtered['media'] // Will be in media dropdown
|
||||
|
||||
|
||||
// Reorganize text-formatting commands
|
||||
if (filtered['text-formatting']) {
|
||||
const allCommands = filtered['text-formatting'].commands
|
||||
const basicFormatting = []
|
||||
const advancedFormatting = []
|
||||
|
||||
|
||||
// Group basic formatting first
|
||||
const basicOrder = ['bold', 'italic', 'underline', 'strike']
|
||||
basicOrder.forEach(name => {
|
||||
const cmd = allCommands.find(c => c.name === name)
|
||||
basicOrder.forEach((name) => {
|
||||
const cmd = allCommands.find((c) => c.name === name)
|
||||
if (cmd) basicFormatting.push(cmd)
|
||||
})
|
||||
|
||||
|
||||
// Then link and code
|
||||
const advancedOrder = ['link', 'code']
|
||||
advancedOrder.forEach(name => {
|
||||
const cmd = allCommands.find(c => c.name === name)
|
||||
advancedOrder.forEach((name) => {
|
||||
const cmd = allCommands.find((c) => c.name === name)
|
||||
if (cmd) advancedFormatting.push(cmd)
|
||||
})
|
||||
|
||||
|
||||
// Create two groups
|
||||
filtered['basic-formatting'] = {
|
||||
name: 'Basic Formatting',
|
||||
label: 'Basic Formatting',
|
||||
commands: basicFormatting
|
||||
}
|
||||
|
||||
|
||||
filtered['advanced-formatting'] = {
|
||||
name: 'Advanced Formatting',
|
||||
label: 'Advanced Formatting',
|
||||
commands: advancedFormatting
|
||||
}
|
||||
|
||||
|
||||
// Remove original text-formatting
|
||||
delete filtered['text-formatting']
|
||||
}
|
||||
|
||||
|
||||
return filtered
|
||||
}
|
||||
|
||||
|
||||
// Get media commands, but filter out iframe
|
||||
const getMediaCommands = () => {
|
||||
if (commands.media) {
|
||||
return commands.media.commands.filter(cmd => cmd.name !== 'iframe-placeholder')
|
||||
return commands.media.commands.filter((cmd) => cmd.name !== 'iframe-placeholder')
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
|
||||
const filteredCommands = getFilteredCommands()
|
||||
const colorCommands = commands.colors.commands
|
||||
const fontCommands = commands.fonts.commands
|
||||
const excludedCommands = ['colors', 'fonts']
|
||||
|
||||
|
||||
// Get current text style for dropdown
|
||||
const getCurrentTextStyle = (editor: Editor) => {
|
||||
if (editor.isActive('heading', { level: 1 })) return 'Heading 1'
|
||||
|
|
@ -143,7 +143,7 @@
|
|||
if (editor.isActive('blockquote')) return 'Blockquote'
|
||||
return 'Paragraph'
|
||||
}
|
||||
|
||||
|
||||
// Calculate dropdown position
|
||||
const updateDropdownPosition = () => {
|
||||
if (dropdownTriggerRef) {
|
||||
|
|
@ -154,7 +154,7 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Toggle dropdown with position update
|
||||
const toggleDropdown = () => {
|
||||
if (!showTextStyleDropdown) {
|
||||
|
|
@ -162,7 +162,7 @@
|
|||
}
|
||||
showTextStyleDropdown = !showTextStyleDropdown
|
||||
}
|
||||
|
||||
|
||||
// Update media dropdown position
|
||||
const updateMediaDropdownPosition = () => {
|
||||
if (mediaDropdownTriggerRef) {
|
||||
|
|
@ -173,7 +173,7 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Toggle media dropdown
|
||||
const toggleMediaDropdown = () => {
|
||||
if (!showMediaDropdown) {
|
||||
|
|
@ -181,7 +181,7 @@
|
|||
}
|
||||
showMediaDropdown = !showMediaDropdown
|
||||
}
|
||||
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement
|
||||
|
|
@ -192,7 +192,7 @@
|
|||
showMediaDropdown = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
$effect(() => {
|
||||
if (showTextStyleDropdown || showMediaDropdown) {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
|
|
@ -341,18 +341,26 @@
|
|||
<div class="edra-toolbar">
|
||||
<!-- Text Style Dropdown -->
|
||||
<div class="text-style-dropdown">
|
||||
<button
|
||||
bind:this={dropdownTriggerRef}
|
||||
class="dropdown-trigger"
|
||||
onclick={toggleDropdown}
|
||||
>
|
||||
<button bind:this={dropdownTriggerRef} class="dropdown-trigger" onclick={toggleDropdown}>
|
||||
<span>{getCurrentTextStyle(editor)}</span>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M3 4.5L6 7.5L9 4.5"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
<span class="separator"></span>
|
||||
|
||||
{#each Object.keys(filteredCommands).filter((key) => !excludedCommands.includes(key)) as keys}
|
||||
|
|
@ -371,8 +379,20 @@
|
|||
onclick={toggleMediaDropdown}
|
||||
>
|
||||
<span>Insert</span>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M3 4.5L6 7.5L9 4.5"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -384,14 +404,14 @@
|
|||
{editor}
|
||||
style={`color: ${editor.getAttributes('textStyle').color};`}
|
||||
onclick={() => {
|
||||
const color = editor.getAttributes('textStyle').color;
|
||||
const hasColor = editor.isActive('textStyle', { color });
|
||||
const color = editor.getAttributes('textStyle').color
|
||||
const hasColor = editor.isActive('textStyle', { color })
|
||||
if (hasColor) {
|
||||
editor.chain().focus().unsetColor().run();
|
||||
editor.chain().focus().unsetColor().run()
|
||||
} else {
|
||||
const color = prompt('Enter the color of the text:');
|
||||
const color = prompt('Enter the color of the text:')
|
||||
if (color !== null) {
|
||||
editor.chain().focus().setColor(color).run();
|
||||
editor.chain().focus().setColor(color).run()
|
||||
}
|
||||
}
|
||||
}}
|
||||
|
|
@ -401,13 +421,13 @@
|
|||
{editor}
|
||||
style={`background-color: ${editor.getAttributes('highlight').color};`}
|
||||
onclick={() => {
|
||||
const hasHightlight = editor.isActive('highlight');
|
||||
const hasHightlight = editor.isActive('highlight')
|
||||
if (hasHightlight) {
|
||||
editor.chain().focus().unsetHighlight().run();
|
||||
editor.chain().focus().unsetHighlight().run()
|
||||
} else {
|
||||
const color = prompt('Enter the color of the highlight:');
|
||||
const color = prompt('Enter the color of the highlight:')
|
||||
if (color !== null) {
|
||||
editor.chain().focus().setHighlight({ color }).run();
|
||||
editor.chain().focus().setHighlight({ color }).run()
|
||||
}
|
||||
}
|
||||
}}
|
||||
|
|
@ -445,38 +465,77 @@
|
|||
|
||||
<!-- Media Dropdown Portal -->
|
||||
{#if showMediaDropdown}
|
||||
<div
|
||||
<div
|
||||
class="media-dropdown-portal"
|
||||
style="position: fixed; top: {mediaDropdownPosition.top}px; left: {mediaDropdownPosition.left}px; z-index: 10000;"
|
||||
>
|
||||
<div class="dropdown-menu">
|
||||
<button class="dropdown-item" onclick={() => {
|
||||
editor?.chain().focus().insertImagePlaceholder().run()
|
||||
showMediaDropdown = false
|
||||
}}>
|
||||
<button
|
||||
class="dropdown-item"
|
||||
onclick={() => {
|
||||
editor?.chain().focus().insertImagePlaceholder().run()
|
||||
showMediaDropdown = false
|
||||
}}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" class="dropdown-icon">
|
||||
<rect x="3" y="5" width="14" height="10" stroke="currentColor" stroke-width="2" fill="none" rx="1"/>
|
||||
<circle cx="7" cy="9" r="1.5" stroke="currentColor" stroke-width="2" fill="none"/>
|
||||
<path d="M3 12L7 8L10 11L13 8L17 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||
<rect
|
||||
x="3"
|
||||
y="5"
|
||||
width="14"
|
||||
height="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
fill="none"
|
||||
rx="1"
|
||||
/>
|
||||
<circle cx="7" cy="9" r="1.5" stroke="currentColor" stroke-width="2" fill="none" />
|
||||
<path
|
||||
d="M3 12L7 8L10 11L13 8L17 12"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
<span>Image</span>
|
||||
</button>
|
||||
<button class="dropdown-item" onclick={() => {
|
||||
editor?.chain().focus().insertVideoPlaceholder().run()
|
||||
showMediaDropdown = false
|
||||
}}>
|
||||
<button
|
||||
class="dropdown-item"
|
||||
onclick={() => {
|
||||
editor?.chain().focus().insertVideoPlaceholder().run()
|
||||
showMediaDropdown = false
|
||||
}}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" class="dropdown-icon">
|
||||
<rect x="3" y="4" width="14" height="12" stroke="currentColor" stroke-width="2" fill="none" rx="2"/>
|
||||
<path d="M8 8.5L12 10L8 11.5V8.5Z" fill="currentColor"/>
|
||||
<rect
|
||||
x="3"
|
||||
y="4"
|
||||
width="14"
|
||||
height="12"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
fill="none"
|
||||
rx="2"
|
||||
/>
|
||||
<path d="M8 8.5L12 10L8 11.5V8.5Z" fill="currentColor" />
|
||||
</svg>
|
||||
<span>Video</span>
|
||||
</button>
|
||||
<button class="dropdown-item" onclick={() => {
|
||||
editor?.chain().focus().insertAudioPlaceholder().run()
|
||||
showMediaDropdown = false
|
||||
}}>
|
||||
<button
|
||||
class="dropdown-item"
|
||||
onclick={() => {
|
||||
editor?.chain().focus().insertAudioPlaceholder().run()
|
||||
showMediaDropdown = false
|
||||
}}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" class="dropdown-icon">
|
||||
<path d="M10 4L10 16M6 8L6 12M14 8L14 12M2 6L2 14M18 6L18 14" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
<path
|
||||
d="M10 4L10 16M6 8L6 12M14 8L14 12M2 6L2 14M18 6L18 14"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
<span>Audio</span>
|
||||
</button>
|
||||
|
|
@ -486,66 +545,93 @@
|
|||
|
||||
<!-- Dropdown Menu Portal -->
|
||||
{#if showTextStyleDropdown}
|
||||
<div
|
||||
<div
|
||||
class="dropdown-menu-portal"
|
||||
style="position: fixed; top: {dropdownPosition.top}px; left: {dropdownPosition.left}px; z-index: 10000;"
|
||||
>
|
||||
<div class="dropdown-menu">
|
||||
<button class="dropdown-item" onclick={() => {
|
||||
editor?.chain().focus().setParagraph().run()
|
||||
showTextStyleDropdown = false
|
||||
}}>
|
||||
<button
|
||||
class="dropdown-item"
|
||||
onclick={() => {
|
||||
editor?.chain().focus().setParagraph().run()
|
||||
showTextStyleDropdown = false
|
||||
}}
|
||||
>
|
||||
Paragraph
|
||||
</button>
|
||||
<div class="dropdown-separator"></div>
|
||||
<button class="dropdown-item" onclick={() => {
|
||||
editor?.chain().focus().toggleHeading({ level: 1 }).run()
|
||||
showTextStyleDropdown = false
|
||||
}}>
|
||||
<button
|
||||
class="dropdown-item"
|
||||
onclick={() => {
|
||||
editor?.chain().focus().toggleHeading({ level: 1 }).run()
|
||||
showTextStyleDropdown = false
|
||||
}}
|
||||
>
|
||||
Heading 1
|
||||
</button>
|
||||
<button class="dropdown-item" onclick={() => {
|
||||
editor?.chain().focus().toggleHeading({ level: 2 }).run()
|
||||
showTextStyleDropdown = false
|
||||
}}>
|
||||
<button
|
||||
class="dropdown-item"
|
||||
onclick={() => {
|
||||
editor?.chain().focus().toggleHeading({ level: 2 }).run()
|
||||
showTextStyleDropdown = false
|
||||
}}
|
||||
>
|
||||
Heading 2
|
||||
</button>
|
||||
<button class="dropdown-item" onclick={() => {
|
||||
editor?.chain().focus().toggleHeading({ level: 3 }).run()
|
||||
showTextStyleDropdown = false
|
||||
}}>
|
||||
<button
|
||||
class="dropdown-item"
|
||||
onclick={() => {
|
||||
editor?.chain().focus().toggleHeading({ level: 3 }).run()
|
||||
showTextStyleDropdown = false
|
||||
}}
|
||||
>
|
||||
Heading 3
|
||||
</button>
|
||||
<div class="dropdown-separator"></div>
|
||||
<button class="dropdown-item" onclick={() => {
|
||||
editor?.chain().focus().toggleBulletList().run()
|
||||
showTextStyleDropdown = false
|
||||
}}>
|
||||
<button
|
||||
class="dropdown-item"
|
||||
onclick={() => {
|
||||
editor?.chain().focus().toggleBulletList().run()
|
||||
showTextStyleDropdown = false
|
||||
}}
|
||||
>
|
||||
Unordered List
|
||||
</button>
|
||||
<button class="dropdown-item" onclick={() => {
|
||||
editor?.chain().focus().toggleOrderedList().run()
|
||||
showTextStyleDropdown = false
|
||||
}}>
|
||||
<button
|
||||
class="dropdown-item"
|
||||
onclick={() => {
|
||||
editor?.chain().focus().toggleOrderedList().run()
|
||||
showTextStyleDropdown = false
|
||||
}}
|
||||
>
|
||||
Ordered List
|
||||
</button>
|
||||
<button class="dropdown-item" onclick={() => {
|
||||
editor?.chain().focus().toggleTaskList().run()
|
||||
showTextStyleDropdown = false
|
||||
}}>
|
||||
<button
|
||||
class="dropdown-item"
|
||||
onclick={() => {
|
||||
editor?.chain().focus().toggleTaskList().run()
|
||||
showTextStyleDropdown = false
|
||||
}}
|
||||
>
|
||||
Task List
|
||||
</button>
|
||||
<div class="dropdown-separator"></div>
|
||||
<button class="dropdown-item" onclick={() => {
|
||||
editor?.chain().focus().toggleCodeBlock().run()
|
||||
showTextStyleDropdown = false
|
||||
}}>
|
||||
<button
|
||||
class="dropdown-item"
|
||||
onclick={() => {
|
||||
editor?.chain().focus().toggleCodeBlock().run()
|
||||
showTextStyleDropdown = false
|
||||
}}
|
||||
>
|
||||
Code Block
|
||||
</button>
|
||||
<button class="dropdown-item" onclick={() => {
|
||||
editor?.chain().focus().toggleBlockquote().run()
|
||||
showTextStyleDropdown = false
|
||||
}}>
|
||||
<button
|
||||
class="dropdown-item"
|
||||
onclick={() => {
|
||||
editor?.chain().focus().toggleBlockquote().run()
|
||||
showTextStyleDropdown = false
|
||||
}}
|
||||
>
|
||||
Blockquote
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -655,7 +741,7 @@
|
|||
.dropdown-item:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
|
||||
.dropdown-icon {
|
||||
flex-shrink: 0;
|
||||
width: 20px;
|
||||
|
|
@ -693,7 +779,7 @@
|
|||
}
|
||||
|
||||
:global(.edra-toolbar button.active),
|
||||
:global(.edra-toolbar button[data-active="true"]) {
|
||||
:global(.edra-toolbar button[data-active='true']) {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
|
|
|||
568
src/lib/components/admin/EssayForm.svelte
Normal file
568
src/lib/components/admin/EssayForm.svelte
Normal file
|
|
@ -0,0 +1,568 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation'
|
||||
import AdminPage from './AdminPage.svelte'
|
||||
import AdminSegmentedControl from './AdminSegmentedControl.svelte'
|
||||
import Editor from './Editor.svelte'
|
||||
import Button from './Button.svelte'
|
||||
import Input from './Input.svelte'
|
||||
import type { JSONContent } from '@tiptap/core'
|
||||
|
||||
interface Props {
|
||||
postId?: number
|
||||
initialData?: {
|
||||
title: string
|
||||
slug: string
|
||||
excerpt: string
|
||||
content: JSONContent
|
||||
tags: string[]
|
||||
status: 'draft' | 'published'
|
||||
}
|
||||
mode: 'create' | 'edit'
|
||||
}
|
||||
|
||||
let { postId, initialData, mode }: Props = $props()
|
||||
|
||||
// State
|
||||
let isLoading = $state(false)
|
||||
let isSaving = $state(false)
|
||||
let error = $state('')
|
||||
let successMessage = $state('')
|
||||
let activeTab = $state('metadata')
|
||||
let showPublishMenu = $state(false)
|
||||
|
||||
// Form data
|
||||
let title = $state(initialData?.title || '')
|
||||
let slug = $state(initialData?.slug || '')
|
||||
let excerpt = $state(initialData?.excerpt || '')
|
||||
let content = $state<JSONContent>(initialData?.content || { type: 'doc', content: [] })
|
||||
let tags = $state<string[]>(initialData?.tags || [])
|
||||
let status = $state<'draft' | 'published'>(initialData?.status || 'draft')
|
||||
let tagInput = $state('')
|
||||
|
||||
// Ref to the editor component
|
||||
let editorRef: any
|
||||
|
||||
const tabOptions = [
|
||||
{ value: 'metadata', label: 'Metadata' },
|
||||
{ value: 'content', label: 'Content' }
|
||||
]
|
||||
|
||||
// Auto-generate slug from title
|
||||
$effect(() => {
|
||||
if (title && !slug) {
|
||||
slug = title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
}
|
||||
})
|
||||
|
||||
function addTag() {
|
||||
if (tagInput && !tags.includes(tagInput)) {
|
||||
tags = [...tags, tagInput]
|
||||
tagInput = ''
|
||||
}
|
||||
}
|
||||
|
||||
function removeTag(tag: string) {
|
||||
tags = tags.filter((t) => t !== tag)
|
||||
}
|
||||
|
||||
function handleEditorChange(newContent: JSONContent) {
|
||||
content = newContent
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
// Check if we're on the content tab and should save editor content
|
||||
if (activeTab === 'content' && editorRef) {
|
||||
const editorData = await editorRef.save()
|
||||
if (editorData) {
|
||||
content = editorData
|
||||
}
|
||||
}
|
||||
|
||||
if (!title) {
|
||||
error = 'Title is required'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
isSaving = true
|
||||
error = ''
|
||||
successMessage = ''
|
||||
|
||||
const auth = localStorage.getItem('admin_auth')
|
||||
if (!auth) {
|
||||
goto('/admin/login')
|
||||
return
|
||||
}
|
||||
|
||||
const payload = {
|
||||
title,
|
||||
slug,
|
||||
postType: 'blog', // 'blog' is the database value for essays
|
||||
status,
|
||||
content,
|
||||
excerpt,
|
||||
tags
|
||||
}
|
||||
|
||||
const url = mode === 'edit' ? `/api/posts/${postId}` : '/api/posts'
|
||||
const method = mode === 'edit' ? 'PUT' : 'POST'
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `Basic ${auth}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to ${mode === 'edit' ? 'save' : 'create'} essay`)
|
||||
}
|
||||
|
||||
const savedPost = await response.json()
|
||||
successMessage = `Essay ${mode === 'edit' ? 'saved' : 'created'} successfully!`
|
||||
|
||||
setTimeout(() => {
|
||||
successMessage = ''
|
||||
if (mode === 'create') {
|
||||
goto(`/admin/posts/${savedPost.id}/edit`)
|
||||
}
|
||||
}, 1500)
|
||||
} catch (err) {
|
||||
error = `Failed to ${mode === 'edit' ? 'save' : 'create'} essay`
|
||||
console.error(err)
|
||||
} finally {
|
||||
isSaving = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePublish() {
|
||||
status = 'published'
|
||||
await handleSave()
|
||||
showPublishMenu = false
|
||||
}
|
||||
|
||||
async function handleUnpublish() {
|
||||
status = 'draft'
|
||||
await handleSave()
|
||||
showPublishMenu = false
|
||||
}
|
||||
|
||||
function togglePublishMenu() {
|
||||
showPublishMenu = !showPublishMenu
|
||||
}
|
||||
|
||||
// Close menu when clicking outside
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
const target = event.target as HTMLElement
|
||||
if (!target.closest('.save-actions')) {
|
||||
showPublishMenu = false
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (showPublishMenu) {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
return () => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<AdminPage>
|
||||
<header slot="header">
|
||||
<div class="header-left">
|
||||
<Button variant="ghost" iconOnly onclick={() => goto('/admin/posts')}>
|
||||
<svg slot="icon" 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"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
<div class="header-center">
|
||||
<AdminSegmentedControl
|
||||
options={tabOptions}
|
||||
value={activeTab}
|
||||
onChange={(value) => (activeTab = value)}
|
||||
/>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<div class="save-actions">
|
||||
<Button variant="primary" onclick={handleSave} disabled={isSaving} class="save-button">
|
||||
{isSaving ? 'Saving...' : status === 'published' ? 'Save' : 'Save Draft'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
iconOnly
|
||||
size="medium"
|
||||
active={showPublishMenu}
|
||||
onclick={togglePublishMenu}
|
||||
disabled={isSaving}
|
||||
class="chevron-button"
|
||||
>
|
||||
<svg
|
||||
slot="icon"
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M3 4.5L6 7.5L9 4.5"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
{#if showPublishMenu}
|
||||
<div class="publish-menu">
|
||||
{#if status === 'published'}
|
||||
<Button variant="ghost" onclick={handleUnpublish} class="menu-item" fullWidth>
|
||||
Unpublish
|
||||
</Button>
|
||||
{:else}
|
||||
<Button variant="ghost" onclick={handlePublish} class="menu-item" fullWidth>
|
||||
Publish
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="admin-container">
|
||||
{#if error}
|
||||
<div class="error-message">{error}</div>
|
||||
{/if}
|
||||
|
||||
{#if successMessage}
|
||||
<div class="success-message">{successMessage}</div>
|
||||
{/if}
|
||||
|
||||
<div class="tab-panels">
|
||||
<!-- Metadata Panel -->
|
||||
<div class="panel content-wrapper" class:active={activeTab === 'metadata'}>
|
||||
<div class="form-content">
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault()
|
||||
handleSave()
|
||||
}}
|
||||
>
|
||||
<div class="form-section">
|
||||
<Input
|
||||
label="Title"
|
||||
bind:value={title}
|
||||
required
|
||||
placeholder="Essay title"
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Slug"
|
||||
bind:value={slug}
|
||||
placeholder="essay-url-slug"
|
||||
/>
|
||||
|
||||
<Input
|
||||
type="textarea"
|
||||
label="Excerpt"
|
||||
helpText="Brief description shown in post lists"
|
||||
bind:value={excerpt}
|
||||
rows={3}
|
||||
placeholder="A brief summary of your essay..."
|
||||
/>
|
||||
|
||||
<div class="tags-field">
|
||||
<label class="input-label">Tags</label>
|
||||
<div class="tag-input-wrapper">
|
||||
<Input
|
||||
bind:value={tagInput}
|
||||
onkeydown={(e) => e.key === 'Enter' && (e.preventDefault(), addTag())}
|
||||
placeholder="Add tags..."
|
||||
wrapperClass="tag-input"
|
||||
/>
|
||||
<Button variant="secondary" size="small" type="button" onclick={addTag}>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
{#if tags.length > 0}
|
||||
<div class="tags">
|
||||
{#each tags as tag}
|
||||
<span class="tag">
|
||||
{tag}
|
||||
<Button
|
||||
variant="ghost"
|
||||
iconOnly
|
||||
size="small"
|
||||
onclick={() => removeTag(tag)}
|
||||
aria-label="Remove {tag}"
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content Panel -->
|
||||
<div class="panel content-wrapper" class:active={activeTab === 'content'}>
|
||||
<div class="editor-content">
|
||||
<Editor
|
||||
bind:this={editorRef}
|
||||
bind:data={content}
|
||||
onChange={handleEditorChange}
|
||||
placeholder="Write your essay..."
|
||||
minHeight={400}
|
||||
autofocus={false}
|
||||
class="essay-editor"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AdminPage>
|
||||
|
||||
<style lang="scss">
|
||||
header {
|
||||
display: grid;
|
||||
grid-template-columns: 250px 1fr 250px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
gap: $unit-2x;
|
||||
|
||||
.header-left {
|
||||
width: 250px;
|
||||
}
|
||||
|
||||
.header-center {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
width: 250px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.admin-container {
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 0 $unit-2x $unit-4x;
|
||||
box-sizing: border-box;
|
||||
|
||||
@include breakpoint('phone') {
|
||||
padding: 0 $unit-2x $unit-2x;
|
||||
}
|
||||
}
|
||||
|
||||
.save-actions {
|
||||
position: relative;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
|
||||
// Custom styles for save/publish buttons to maintain grey color scheme
|
||||
:global(.save-button.btn-primary) {
|
||||
background-color: $grey-10;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: $grey-20;
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
background-color: $grey-30;
|
||||
}
|
||||
}
|
||||
|
||||
.save-button {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
padding-right: $unit-2x;
|
||||
}
|
||||
|
||||
:global(.chevron-button.btn-primary) {
|
||||
background-color: $grey-10;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: $grey-20;
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
background-color: $grey-30;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: $grey-20;
|
||||
}
|
||||
}
|
||||
|
||||
.chevron-button {
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.2);
|
||||
|
||||
svg {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
&.active svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
.publish-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
margin-top: $unit;
|
||||
background: white;
|
||||
border-radius: $unit;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
overflow: hidden;
|
||||
min-width: 120px;
|
||||
z-index: 100;
|
||||
|
||||
.menu-item {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
.tab-panels {
|
||||
position: relative;
|
||||
|
||||
.panel {
|
||||
display: none;
|
||||
box-sizing: border-box;
|
||||
|
||||
&.active {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
background: white;
|
||||
border-radius: $unit-2x;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.error-message,
|
||||
.success-message {
|
||||
padding: $unit-3x;
|
||||
border-radius: $unit;
|
||||
margin-bottom: $unit-4x;
|
||||
max-width: 700px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background-color: #fee;
|
||||
color: #d33;
|
||||
}
|
||||
|
||||
.success-message {
|
||||
background-color: #efe;
|
||||
color: #363;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
margin-bottom: $unit-6x;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.form-content {
|
||||
padding: $unit-4x;
|
||||
|
||||
@include breakpoint('phone') {
|
||||
padding: $unit-3x;
|
||||
}
|
||||
}
|
||||
|
||||
// Tags field styles
|
||||
.tags-field {
|
||||
margin-bottom: $unit-4x;
|
||||
|
||||
.input-label {
|
||||
display: block;
|
||||
margin-bottom: $unit;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: $grey-20;
|
||||
}
|
||||
}
|
||||
|
||||
.tag-input-wrapper {
|
||||
display: flex;
|
||||
gap: $unit;
|
||||
|
||||
:global(.tag-input) {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: $unit;
|
||||
margin-top: $unit-2x;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: $unit $unit-2x;
|
||||
background: $grey-90;
|
||||
border-radius: 20px;
|
||||
font-size: 0.875rem;
|
||||
color: $grey-20;
|
||||
|
||||
:global(.btn) {
|
||||
margin-left: 4px;
|
||||
font-size: 1.125rem;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.editor-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
:global(.essay-editor) {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -50,7 +50,6 @@
|
|||
margin-bottom: $unit;
|
||||
font-weight: 500;
|
||||
color: $grey-20;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
font-size: 0.925rem;
|
||||
|
||||
.required {
|
||||
|
|
@ -63,13 +62,11 @@
|
|||
margin-top: $unit;
|
||||
color: #c33;
|
||||
font-size: 0.875rem;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
|
||||
.help-text {
|
||||
margin-top: $unit;
|
||||
color: $grey-40;
|
||||
font-size: 0.875rem;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
601
src/lib/components/admin/GalleryManager.svelte
Normal file
601
src/lib/components/admin/GalleryManager.svelte
Normal file
|
|
@ -0,0 +1,601 @@
|
|||
<script lang="ts">
|
||||
import Button from './Button.svelte'
|
||||
import MediaLibraryModal from './MediaLibraryModal.svelte'
|
||||
import type { Media } from '@prisma/client'
|
||||
|
||||
interface Props {
|
||||
label: string
|
||||
value?: Media[]
|
||||
maxItems?: number
|
||||
required?: boolean
|
||||
error?: string
|
||||
showFileInfo?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
label,
|
||||
value = $bindable([]),
|
||||
maxItems,
|
||||
required = false,
|
||||
error,
|
||||
showFileInfo = false
|
||||
}: Props = $props()
|
||||
|
||||
let showModal = $state(false)
|
||||
let draggedIndex = $state<number | null>(null)
|
||||
let dragOverIndex = $state<number | null>(null)
|
||||
|
||||
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))
|
||||
|
||||
if (maxItems) {
|
||||
const availableSlots = maxItems - value.length
|
||||
value = [...value, ...newImages.slice(0, availableSlots)]
|
||||
} else {
|
||||
value = [...value, ...newImages]
|
||||
}
|
||||
|
||||
showModal = false
|
||||
}
|
||||
|
||||
function removeImage(index: number) {
|
||||
value = value.filter((_, i) => i !== index)
|
||||
}
|
||||
|
||||
function openModal() {
|
||||
showModal = true
|
||||
}
|
||||
|
||||
// Drag and Drop functionality
|
||||
function handleDragStart(event: DragEvent, index: number) {
|
||||
if (!event.dataTransfer) return
|
||||
|
||||
draggedIndex = index
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
event.dataTransfer.setData('text/html', '')
|
||||
|
||||
// Add dragging class to the dragged element
|
||||
const target = event.target as HTMLElement
|
||||
target.style.opacity = '0.5'
|
||||
}
|
||||
|
||||
function handleDragEnd(event: DragEvent) {
|
||||
const target = event.target as HTMLElement
|
||||
target.style.opacity = '1'
|
||||
|
||||
draggedIndex = null
|
||||
dragOverIndex = null
|
||||
}
|
||||
|
||||
function handleDragOver(event: DragEvent, index: number) {
|
||||
event.preventDefault()
|
||||
if (!event.dataTransfer) return
|
||||
|
||||
event.dataTransfer.dropEffect = 'move'
|
||||
dragOverIndex = index
|
||||
}
|
||||
|
||||
function handleDragLeave() {
|
||||
dragOverIndex = null
|
||||
}
|
||||
|
||||
function handleDrop(event: DragEvent, dropIndex: number) {
|
||||
event.preventDefault()
|
||||
|
||||
if (draggedIndex === null || draggedIndex === dropIndex) {
|
||||
return
|
||||
}
|
||||
|
||||
// Reorder the array
|
||||
const newValue = [...value]
|
||||
const draggedItem = newValue[draggedIndex]
|
||||
|
||||
// Remove the dragged item
|
||||
newValue.splice(draggedIndex, 1)
|
||||
|
||||
// Insert at the new position (adjust index if necessary)
|
||||
const insertIndex = draggedIndex < dropIndex ? dropIndex - 1 : dropIndex
|
||||
newValue.splice(insertIndex, 0, draggedItem)
|
||||
|
||||
value = newValue
|
||||
|
||||
// Reset drag state
|
||||
draggedIndex = null
|
||||
dragOverIndex = null
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
// 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`
|
||||
)
|
||||
</script>
|
||||
|
||||
<div class="gallery-manager">
|
||||
<div class="header">
|
||||
<label class="input-label">
|
||||
{label}
|
||||
{#if required}
|
||||
<span class="required">*</span>
|
||||
{/if}
|
||||
</label>
|
||||
|
||||
{#if hasImages}
|
||||
<span class="items-count">
|
||||
{itemsText}
|
||||
{#if maxItems}
|
||||
of {maxItems} max
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Gallery Grid -->
|
||||
{#if hasImages}
|
||||
<div class="gallery-grid" class:has-error={error}>
|
||||
{#each value as item, index (item.id)}
|
||||
<div
|
||||
class="gallery-item"
|
||||
class:drag-over={dragOverIndex === index}
|
||||
draggable="true"
|
||||
ondragstart={(e) => handleDragStart(e, index)}
|
||||
ondragend={handleDragEnd}
|
||||
ondragover={(e) => handleDragOver(e, index)}
|
||||
ondragleave={handleDragLeave}
|
||||
ondrop={(e) => handleDrop(e, index)}
|
||||
>
|
||||
<!-- 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>
|
||||
</div>
|
||||
|
||||
<!-- Image -->
|
||||
<div class="image-container">
|
||||
{#if item.thumbnailUrl}
|
||||
<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>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Image Info -->
|
||||
{#if showFileInfo}
|
||||
<div class="image-info">
|
||||
<p class="filename">{item.filename}</p>
|
||||
<p class="file-size">{formatFileSize(item.size)}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Remove Button -->
|
||||
<button
|
||||
type="button"
|
||||
class="remove-button"
|
||||
onclick={() => removeImage(index)}
|
||||
aria-label="Remove image"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M6 6L18 18M6 18L18 6"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Order Indicator -->
|
||||
<div class="order-indicator">
|
||||
{index + 1}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<!-- Add More Button (if within grid) -->
|
||||
{#if canAddMore}
|
||||
<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">
|
||||
<path
|
||||
d="M12 5v14m-7-7h14"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<span>Add Images</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Empty State -->
|
||||
<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>
|
||||
</div>
|
||||
<p class="empty-text">No images added yet</p>
|
||||
<Button variant="primary" onclick={openModal}>
|
||||
<svg
|
||||
slot="icon"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12 5v14m-7-7h14"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
Add Images
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Add More Button (outside grid) -->
|
||||
{#if hasImages && canAddMore}
|
||||
<div class="add-more-container">
|
||||
<Button variant="ghost" onclick={openModal}>
|
||||
<svg
|
||||
slot="icon"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12 5v14m-7-7h14"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
Add More Images
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Error Message -->
|
||||
{#if error}
|
||||
<p class="error-message">{error}</p>
|
||||
{/if}
|
||||
|
||||
<!-- Help Text -->
|
||||
{#if hasImages}
|
||||
<p class="help-text">Drag and drop to reorder images</p>
|
||||
{/if}
|
||||
|
||||
<!-- Media Library Modal -->
|
||||
<MediaLibraryModal
|
||||
bind:isOpen={showModal}
|
||||
mode="multiple"
|
||||
fileType="image"
|
||||
{selectedIds}
|
||||
title="Add Images to Gallery"
|
||||
confirmText="Add Selected Images"
|
||||
onselect={handleImagesSelect}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.gallery-manager {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: $unit-2x;
|
||||
}
|
||||
|
||||
.input-label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: $grey-20;
|
||||
|
||||
.required {
|
||||
color: $red-60;
|
||||
margin-left: $unit-half;
|
||||
}
|
||||
}
|
||||
|
||||
.items-count {
|
||||
font-size: 0.75rem;
|
||||
color: $grey-40;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.gallery-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: $unit-2x;
|
||||
padding: $unit-2x;
|
||||
border: 1px solid $grey-85;
|
||||
border-radius: $card-corner-radius;
|
||||
background-color: $grey-97;
|
||||
|
||||
&.has-error {
|
||||
border-color: $red-60;
|
||||
}
|
||||
}
|
||||
|
||||
.gallery-item {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
border-radius: $card-corner-radius;
|
||||
overflow: hidden;
|
||||
cursor: move;
|
||||
transition: all 0.2s ease;
|
||||
background-color: white;
|
||||
border: 1px solid $grey-90;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
|
||||
.remove-button {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&.drag-over {
|
||||
border-color: $blue-60;
|
||||
background-color: rgba(59, 130, 246, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
position: absolute;
|
||||
top: $unit-half;
|
||||
left: $unit-half;
|
||||
z-index: 3;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
color: white;
|
||||
padding: $unit-half;
|
||||
border-radius: 4px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
pointer-events: none;
|
||||
|
||||
.gallery-item:hover & {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.image-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.image-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: $grey-95;
|
||||
color: $grey-60;
|
||||
}
|
||||
|
||||
.image-info {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
|
||||
padding: $unit-2x $unit $unit;
|
||||
color: white;
|
||||
|
||||
.filename {
|
||||
margin: 0 0 $unit-fourth 0;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.file-size {
|
||||
margin: 0;
|
||||
font-size: 0.625rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.remove-button {
|
||||
position: absolute;
|
||||
top: $unit-half;
|
||||
right: $unit-half;
|
||||
z-index: 3;
|
||||
background-color: rgba(239, 68, 68, 0.9);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: $red-60;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
.order-indicator {
|
||||
position: absolute;
|
||||
top: $unit-half;
|
||||
right: $unit-half;
|
||||
z-index: 2;
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
color: white;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
padding: $unit-fourth $unit-half;
|
||||
border-radius: 12px;
|
||||
min-width: 20px;
|
||||
text-align: center;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.add-more-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: $unit;
|
||||
aspect-ratio: 1;
|
||||
border: 2px dashed $grey-70;
|
||||
border-radius: $card-corner-radius;
|
||||
background-color: transparent;
|
||||
color: $grey-50;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
|
||||
&:hover {
|
||||
border-color: $blue-60;
|
||||
color: $blue-60;
|
||||
background-color: rgba(59, 130, 246, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
.add-icon {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 200px;
|
||||
border: 2px dashed $grey-80;
|
||||
border-radius: $card-corner-radius;
|
||||
background-color: $grey-97;
|
||||
|
||||
&.has-error {
|
||||
border-color: $red-60;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: $unit-2x;
|
||||
text-align: center;
|
||||
padding: $unit-4x;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
color: $grey-60;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
color: $grey-40;
|
||||
}
|
||||
|
||||
.add-more-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding-top: $unit;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: $red-60;
|
||||
}
|
||||
|
||||
.help-text {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: $grey-50;
|
||||
text-align: center;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
// Responsive adjustments
|
||||
@media (max-width: 640px) {
|
||||
.gallery-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
||||
gap: $unit;
|
||||
padding: $unit;
|
||||
}
|
||||
|
||||
.header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: $unit;
|
||||
}
|
||||
|
||||
.order-indicator {
|
||||
font-size: 0.625rem;
|
||||
padding: $unit-fourth $unit-half;
|
||||
}
|
||||
|
||||
.remove-button {
|
||||
opacity: 1; // Always visible on mobile
|
||||
}
|
||||
|
||||
.image-info {
|
||||
display: none; // Hide on mobile to save space
|
||||
}
|
||||
}
|
||||
</style>
|
||||
829
src/lib/components/admin/GalleryUploader.svelte
Normal file
829
src/lib/components/admin/GalleryUploader.svelte
Normal file
|
|
@ -0,0 +1,829 @@
|
|||
<script lang="ts">
|
||||
import type { Media } from '@prisma/client'
|
||||
import Button from './Button.svelte'
|
||||
import Input from './Input.svelte'
|
||||
import SmartImage from '../SmartImage.svelte'
|
||||
import MediaLibraryModal from './MediaLibraryModal.svelte'
|
||||
import { authenticatedFetch } from '$lib/admin-auth'
|
||||
import RefreshIcon from '$icons/refresh.svg?component'
|
||||
|
||||
interface Props {
|
||||
label: string
|
||||
value?: Media[]
|
||||
onUpload: (media: Media[]) => void
|
||||
onReorder?: (media: Media[]) => void
|
||||
maxItems?: number
|
||||
allowAltText?: boolean
|
||||
required?: boolean
|
||||
error?: string
|
||||
placeholder?: string
|
||||
helpText?: string
|
||||
showBrowseLibrary?: boolean
|
||||
maxFileSize?: number // MB limit
|
||||
}
|
||||
|
||||
let {
|
||||
label,
|
||||
value = $bindable([]),
|
||||
onUpload,
|
||||
onReorder,
|
||||
maxItems = 20,
|
||||
allowAltText = true,
|
||||
required = false,
|
||||
error,
|
||||
placeholder = 'Drag and drop images here, or click to browse',
|
||||
helpText,
|
||||
showBrowseLibrary = false,
|
||||
maxFileSize = 10
|
||||
}: Props = $props()
|
||||
|
||||
// State
|
||||
let isUploading = $state(false)
|
||||
let uploadProgress = $state<Record<string, number>>({})
|
||||
let uploadError = $state<string | null>(null)
|
||||
let isDragOver = $state(false)
|
||||
let fileInputElement: HTMLInputElement
|
||||
let draggedIndex = $state<number | null>(null)
|
||||
let draggedOverIndex = $state<number | null>(null)
|
||||
let isMediaLibraryOpen = $state(false)
|
||||
|
||||
// Computed properties
|
||||
const hasImages = $derived(value && value.length > 0)
|
||||
const canAddMore = $derived(!maxItems || !value || value.length < maxItems)
|
||||
const remainingSlots = $derived(maxItems ? maxItems - (value?.length || 0) : Infinity)
|
||||
|
||||
// File validation
|
||||
function validateFile(file: File): string | null {
|
||||
// Check file type
|
||||
if (!file.type.startsWith('image/')) {
|
||||
return 'Please select image files only'
|
||||
}
|
||||
|
||||
// Check file size
|
||||
const sizeMB = file.size / 1024 / 1024
|
||||
if (sizeMB > maxFileSize) {
|
||||
return `File size must be less than ${maxFileSize}MB`
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// Upload multiple files to server
|
||||
async function uploadFiles(files: File[]): Promise<Media[]> {
|
||||
const uploadPromises = files.map(async (file, index) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
const response = await authenticatedFetch('/api/media/upload', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || `Upload failed for ${file.name}`)
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
})
|
||||
|
||||
return Promise.all(uploadPromises)
|
||||
}
|
||||
|
||||
// Handle file selection/drop
|
||||
async function handleFiles(files: FileList) {
|
||||
if (files.length === 0) return
|
||||
|
||||
// Validate files
|
||||
const filesToUpload: File[] = []
|
||||
const errors: string[] = []
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i]
|
||||
const validationError = validateFile(file)
|
||||
|
||||
if (validationError) {
|
||||
errors.push(`${file.name}: ${validationError}`)
|
||||
} else if (filesToUpload.length < remainingSlots) {
|
||||
filesToUpload.push(file)
|
||||
} else {
|
||||
errors.push(`${file.name}: Maximum ${maxItems} images allowed`)
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
uploadError = errors.join('\n')
|
||||
return
|
||||
}
|
||||
|
||||
if (filesToUpload.length === 0) return
|
||||
|
||||
uploadError = null
|
||||
isUploading = true
|
||||
|
||||
try {
|
||||
// Initialize progress tracking
|
||||
const progressKeys = filesToUpload.map((file, index) => `${file.name}-${index}`)
|
||||
uploadProgress = Object.fromEntries(progressKeys.map(key => [key, 0]))
|
||||
|
||||
// Simulate progress for user feedback
|
||||
const progressIntervals = progressKeys.map(key => {
|
||||
return setInterval(() => {
|
||||
if (uploadProgress[key] < 90) {
|
||||
uploadProgress[key] += Math.random() * 10
|
||||
uploadProgress = { ...uploadProgress }
|
||||
}
|
||||
}, 100)
|
||||
})
|
||||
|
||||
const uploadedMedia = await uploadFiles(filesToUpload)
|
||||
|
||||
// Clear progress intervals
|
||||
progressIntervals.forEach(interval => clearInterval(interval))
|
||||
|
||||
// Complete progress
|
||||
progressKeys.forEach(key => {
|
||||
uploadProgress[key] = 100
|
||||
})
|
||||
uploadProgress = { ...uploadProgress }
|
||||
|
||||
// Brief delay to show completion
|
||||
setTimeout(() => {
|
||||
const newValue = [...(value || []), ...uploadedMedia]
|
||||
value = newValue
|
||||
onUpload(newValue)
|
||||
isUploading = false
|
||||
uploadProgress = {}
|
||||
}, 500)
|
||||
|
||||
} catch (err) {
|
||||
isUploading = false
|
||||
uploadProgress = {}
|
||||
uploadError = err instanceof Error ? err.message : 'Upload failed'
|
||||
}
|
||||
}
|
||||
|
||||
// Drag and drop handlers for file upload
|
||||
function handleDragOver(event: DragEvent) {
|
||||
event.preventDefault()
|
||||
isDragOver = true
|
||||
}
|
||||
|
||||
function handleDragLeave(event: DragEvent) {
|
||||
event.preventDefault()
|
||||
isDragOver = false
|
||||
}
|
||||
|
||||
function handleDrop(event: DragEvent) {
|
||||
event.preventDefault()
|
||||
isDragOver = false
|
||||
|
||||
const files = event.dataTransfer?.files
|
||||
if (files) {
|
||||
handleFiles(files)
|
||||
}
|
||||
}
|
||||
|
||||
// Click to browse handler
|
||||
function handleBrowseClick() {
|
||||
fileInputElement?.click()
|
||||
}
|
||||
|
||||
function handleFileInputChange(event: Event) {
|
||||
const target = event.target as HTMLInputElement
|
||||
if (target.files) {
|
||||
handleFiles(target.files)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove individual image
|
||||
function handleRemoveImage(index: number) {
|
||||
if (!value) return
|
||||
const newValue = value.filter((_, i) => i !== index)
|
||||
value = newValue
|
||||
onUpload(newValue)
|
||||
uploadError = null
|
||||
}
|
||||
|
||||
// Update alt text on server
|
||||
async function handleAltTextChange(media: Media, newAltText: string) {
|
||||
if (!media) return
|
||||
|
||||
try {
|
||||
const response = await authenticatedFetch(`/api/media/${media.id}/metadata`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
altText: newAltText.trim() || null
|
||||
})
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const updatedData = await response.json()
|
||||
if (value) {
|
||||
const index = value.findIndex(m => m.id === media.id)
|
||||
if (index !== -1) {
|
||||
value[index] = { ...value[index], altText: updatedData.altText, updatedAt: updatedData.updatedAt }
|
||||
value = [...value]
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update alt text:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Drag and drop reordering handlers
|
||||
function handleImageDragStart(event: DragEvent, index: number) {
|
||||
draggedIndex = index
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
}
|
||||
}
|
||||
|
||||
function handleImageDragOver(event: DragEvent, index: number) {
|
||||
event.preventDefault()
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.dropEffect = 'move'
|
||||
}
|
||||
draggedOverIndex = index
|
||||
}
|
||||
|
||||
function handleImageDragLeave() {
|
||||
draggedOverIndex = null
|
||||
}
|
||||
|
||||
function handleImageDrop(event: DragEvent, dropIndex: number) {
|
||||
event.preventDefault()
|
||||
|
||||
if (draggedIndex === null || !value) return
|
||||
|
||||
const newValue = [...value]
|
||||
const draggedItem = newValue[draggedIndex]
|
||||
|
||||
// Remove from old position
|
||||
newValue.splice(draggedIndex, 1)
|
||||
|
||||
// Insert at new position (adjust index if dragging to later position)
|
||||
const adjustedDropIndex = draggedIndex < dropIndex ? dropIndex - 1 : dropIndex
|
||||
newValue.splice(adjustedDropIndex, 0, draggedItem)
|
||||
|
||||
value = newValue
|
||||
onUpload(newValue)
|
||||
if (onReorder) {
|
||||
onReorder(newValue)
|
||||
}
|
||||
|
||||
draggedIndex = null
|
||||
draggedOverIndex = null
|
||||
}
|
||||
|
||||
function handleImageDragEnd() {
|
||||
draggedIndex = null
|
||||
draggedOverIndex = null
|
||||
}
|
||||
|
||||
// Browse library handler
|
||||
function handleBrowseLibrary() {
|
||||
isMediaLibraryOpen = true
|
||||
}
|
||||
|
||||
function handleMediaSelect(selectedMedia: Media | Media[]) {
|
||||
// For gallery mode, selectedMedia will be an array
|
||||
const mediaArray = Array.isArray(selectedMedia) ? selectedMedia : [selectedMedia]
|
||||
|
||||
// Add selected media to existing gallery (avoid duplicates)
|
||||
const currentIds = value?.map(m => m.id) || []
|
||||
const newMedia = mediaArray.filter(media => !currentIds.includes(media.id))
|
||||
|
||||
if (newMedia.length > 0) {
|
||||
const updatedGallery = [...(value || []), ...newMedia]
|
||||
value = updatedGallery
|
||||
onUpload(updatedGallery)
|
||||
}
|
||||
}
|
||||
|
||||
function handleMediaLibraryClose() {
|
||||
isMediaLibraryOpen = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="gallery-uploader">
|
||||
<!-- Label -->
|
||||
<label class="uploader-label">
|
||||
{label}
|
||||
{#if required}
|
||||
<span class="required">*</span>
|
||||
{/if}
|
||||
</label>
|
||||
|
||||
{#if helpText}
|
||||
<p class="help-text">{helpText}</p>
|
||||
{/if}
|
||||
|
||||
<!-- Upload Area -->
|
||||
{#if !hasImages || (hasImages && canAddMore)}
|
||||
<div
|
||||
class="drop-zone"
|
||||
class:drag-over={isDragOver}
|
||||
class:uploading={isUploading}
|
||||
class:has-error={!!uploadError}
|
||||
ondragover={handleDragOver}
|
||||
ondragleave={handleDragLeave}
|
||||
ondrop={handleDrop}
|
||||
onclick={handleBrowseClick}
|
||||
>
|
||||
{#if isUploading}
|
||||
<!-- Upload Progress -->
|
||||
<div class="upload-progress">
|
||||
<svg class="upload-spinner" width="24" height="24" viewBox="0 0 24 24">
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
fill="none"
|
||||
stroke-dasharray="60"
|
||||
stroke-dashoffset="60"
|
||||
stroke-linecap="round"
|
||||
>
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
type="rotate"
|
||||
from="0 12 12"
|
||||
to="360 12 12"
|
||||
dur="1s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</circle>
|
||||
</svg>
|
||||
<p class="upload-text">Uploading images...</p>
|
||||
|
||||
<!-- Individual file progress -->
|
||||
<div class="file-progress-list">
|
||||
{#each Object.entries(uploadProgress) as [fileName, progress]}
|
||||
<div class="file-progress-item">
|
||||
<span class="file-name">{fileName.split('-')[0]}</span>
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" style="width: {Math.round(progress)}%"></div>
|
||||
</div>
|
||||
<span class="progress-percent">{Math.round(progress)}%</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{: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>
|
||||
<p class="upload-main-text">{placeholder}</p>
|
||||
<p class="upload-sub-text">
|
||||
Supports JPG, PNG, GIF up to {maxFileSize}MB
|
||||
{#if maxItems}
|
||||
• Maximum {maxItems} images
|
||||
{/if}
|
||||
{#if hasImages && remainingSlots < Infinity}
|
||||
• {remainingSlots} slots remaining
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Action Buttons -->
|
||||
{#if !isUploading && canAddMore}
|
||||
<div class="action-buttons">
|
||||
<Button variant="primary" onclick={handleBrowseClick}>
|
||||
{hasImages ? 'Add More Images' : 'Choose Images'}
|
||||
</Button>
|
||||
|
||||
{#if showBrowseLibrary}
|
||||
<Button variant="ghost" onclick={handleBrowseLibrary}>
|
||||
Browse Library
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Image Gallery -->
|
||||
{#if hasImages}
|
||||
<div class="image-gallery">
|
||||
{#each value as media, index (media.id)}
|
||||
<div
|
||||
class="gallery-item"
|
||||
class:dragging={draggedIndex === index}
|
||||
class:drag-over={draggedOverIndex === index}
|
||||
draggable="true"
|
||||
ondragstart={(e) => handleImageDragStart(e, index)}
|
||||
ondragover={(e) => handleImageDragOver(e, index)}
|
||||
ondragleave={handleImageDragLeave}
|
||||
ondrop={(e) => handleImageDrop(e, index)}
|
||||
ondragend={handleImageDragEnd}
|
||||
>
|
||||
<!-- 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="6" r="2" fill="currentColor"/>
|
||||
<circle cx="15" cy="6" r="2" fill="currentColor"/>
|
||||
<circle cx="9" cy="12" r="2" fill="currentColor"/>
|
||||
<circle cx="15" cy="12" r="2" fill="currentColor"/>
|
||||
<circle cx="9" cy="18" r="2" fill="currentColor"/>
|
||||
<circle cx="15" cy="18" r="2" fill="currentColor"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Image Preview -->
|
||||
<div class="image-preview">
|
||||
<SmartImage
|
||||
{media}
|
||||
alt={media.altText || media.filename || 'Gallery image'}
|
||||
containerWidth={300}
|
||||
loading="lazy"
|
||||
aspectRatio="1:1"
|
||||
class="gallery-image"
|
||||
/>
|
||||
|
||||
<!-- Remove Button -->
|
||||
<button
|
||||
class="remove-button"
|
||||
onclick={() => handleRemoveImage(index)}
|
||||
type="button"
|
||||
aria-label="Remove image"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<line x1="18" y1="6" x2="6" y2="18" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<line x1="6" y1="6" x2="18" y2="18" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Alt Text Input -->
|
||||
{#if allowAltText}
|
||||
<div class="alt-text-input">
|
||||
<Input
|
||||
type="text"
|
||||
label="Alt Text"
|
||||
value={media.altText || ''}
|
||||
placeholder="Describe this image"
|
||||
size="small"
|
||||
onblur={(e) => handleAltTextChange(media, e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- File Info -->
|
||||
<div class="file-info">
|
||||
<p class="filename">{media.originalName || media.filename}</p>
|
||||
<p class="file-meta">
|
||||
{Math.round((media.size || 0) / 1024)} KB
|
||||
{#if media.width && media.height}
|
||||
• {media.width}×{media.height}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Error Message -->
|
||||
{#if error || uploadError}
|
||||
<p class="error-message">{error || uploadError}</p>
|
||||
{/if}
|
||||
|
||||
<!-- Hidden File Input -->
|
||||
<input
|
||||
bind:this={fileInputElement}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
style="display: none;"
|
||||
onchange={handleFileInputChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Media Library Modal -->
|
||||
<MediaLibraryModal
|
||||
bind:isOpen={isMediaLibraryOpen}
|
||||
mode="multiple"
|
||||
fileType="image"
|
||||
title="Select Images"
|
||||
confirmText="Add Selected"
|
||||
onSelect={handleMediaSelect}
|
||||
onClose={handleMediaLibraryClose}
|
||||
/>
|
||||
|
||||
<style lang="scss">
|
||||
.gallery-uploader {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-2x;
|
||||
}
|
||||
|
||||
.uploader-label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: $grey-20;
|
||||
|
||||
.required {
|
||||
color: $red-60;
|
||||
margin-left: $unit-half;
|
||||
}
|
||||
}
|
||||
|
||||
.help-text {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
color: $grey-40;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
// Drop Zone Styles
|
||||
.drop-zone {
|
||||
border: 2px dashed $grey-80;
|
||||
border-radius: $card-corner-radius;
|
||||
background-color: $grey-97;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
min-height: 120px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&:hover {
|
||||
border-color: $blue-60;
|
||||
background-color: rgba($blue-60, 0.02);
|
||||
}
|
||||
|
||||
&.drag-over {
|
||||
border-color: $blue-60;
|
||||
background-color: rgba($blue-60, 0.05);
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
&.uploading {
|
||||
cursor: default;
|
||||
border-color: $blue-60;
|
||||
}
|
||||
|
||||
&.has-error {
|
||||
border-color: $red-60;
|
||||
background-color: rgba($red-60, 0.02);
|
||||
}
|
||||
}
|
||||
|
||||
.upload-prompt {
|
||||
text-align: center;
|
||||
padding: $unit-3x;
|
||||
|
||||
.upload-icon {
|
||||
color: $grey-50;
|
||||
margin-bottom: $unit-2x;
|
||||
}
|
||||
|
||||
.upload-main-text {
|
||||
margin: 0 0 $unit 0;
|
||||
font-size: 0.875rem;
|
||||
color: $grey-30;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.upload-sub-text {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: $grey-50;
|
||||
}
|
||||
}
|
||||
|
||||
.upload-progress {
|
||||
text-align: center;
|
||||
padding: $unit-3x;
|
||||
|
||||
.upload-spinner {
|
||||
color: $blue-60;
|
||||
margin-bottom: $unit-2x;
|
||||
}
|
||||
|
||||
.upload-text {
|
||||
margin: 0 0 $unit-2x 0;
|
||||
font-size: 0.875rem;
|
||||
color: $grey-30;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.file-progress-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit;
|
||||
max-width: 300px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.file-progress-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $unit;
|
||||
font-size: 0.75rem;
|
||||
|
||||
.file-name {
|
||||
flex: 1;
|
||||
color: $grey-30;
|
||||
text-align: left;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 60px;
|
||||
height: 4px;
|
||||
background-color: $grey-90;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background-color: $blue-60;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.progress-percent {
|
||||
width: 30px;
|
||||
text-align: right;
|
||||
color: $grey-40;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: $unit-2x;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
// Image Gallery Styles
|
||||
.image-gallery {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: $unit-3x;
|
||||
margin-top: $unit-2x;
|
||||
}
|
||||
|
||||
.gallery-item {
|
||||
position: relative;
|
||||
border: 1px solid $grey-90;
|
||||
border-radius: $card-corner-radius;
|
||||
background-color: white;
|
||||
overflow: hidden;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: $grey-70;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
&.dragging {
|
||||
opacity: 0.5;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
&.drag-over {
|
||||
border-color: $blue-60;
|
||||
background-color: rgba($blue-60, 0.05);
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
position: absolute;
|
||||
top: $unit;
|
||||
left: $unit;
|
||||
z-index: 2;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border-radius: 4px;
|
||||
padding: $unit-half;
|
||||
cursor: grab;
|
||||
color: $grey-40;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .drag-handle {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
overflow: hidden;
|
||||
|
||||
:global(.gallery-image) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.remove-button {
|
||||
position: absolute;
|
||||
top: $unit;
|
||||
right: $unit;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: $grey-40;
|
||||
opacity: 0;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: white;
|
||||
color: $red-60;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .remove-button {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.alt-text-input {
|
||||
padding: $unit-2x;
|
||||
}
|
||||
|
||||
.file-info {
|
||||
padding: $unit-2x;
|
||||
padding-top: $unit;
|
||||
border-top: 1px solid $grey-95;
|
||||
|
||||
.filename {
|
||||
margin: 0 0 $unit-half 0;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: $grey-10;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.file-meta {
|
||||
margin: 0;
|
||||
font-size: 0.7rem;
|
||||
color: $grey-40;
|
||||
}
|
||||
}
|
||||
|
||||
.error-message {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: $red-60;
|
||||
padding: $unit;
|
||||
background-color: rgba($red-60, 0.05);
|
||||
border-radius: $card-corner-radius;
|
||||
border: 1px solid rgba($red-60, 0.2);
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
// Responsive adjustments
|
||||
@media (max-width: 640px) {
|
||||
.image-gallery {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.upload-prompt {
|
||||
padding: $unit-2x;
|
||||
|
||||
.upload-main-text {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
393
src/lib/components/admin/ImagePicker.svelte
Normal file
393
src/lib/components/admin/ImagePicker.svelte
Normal file
|
|
@ -0,0 +1,393 @@
|
|||
<script lang="ts">
|
||||
import Button from './Button.svelte'
|
||||
import MediaLibraryModal from './MediaLibraryModal.svelte'
|
||||
import type { Media } from '@prisma/client'
|
||||
|
||||
interface Props {
|
||||
label: string
|
||||
value?: Media | null
|
||||
aspectRatio?: string
|
||||
placeholder?: string
|
||||
required?: boolean
|
||||
error?: string
|
||||
showDimensions?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
label,
|
||||
value = $bindable(),
|
||||
aspectRatio,
|
||||
placeholder = 'No image selected',
|
||||
required = false,
|
||||
error,
|
||||
showDimensions = true
|
||||
}: Props = $props()
|
||||
|
||||
let showModal = $state(false)
|
||||
let isHovering = $state(false)
|
||||
|
||||
function handleImageSelect(media: Media) {
|
||||
value = media
|
||||
showModal = false
|
||||
}
|
||||
|
||||
function handleClear() {
|
||||
value = null
|
||||
}
|
||||
|
||||
function openModal() {
|
||||
showModal = true
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
// Computed properties
|
||||
const hasImage = $derived(value !== null && value !== undefined)
|
||||
const selectedIds = $derived(hasImage ? [value!.id] : [])
|
||||
|
||||
// Calculate aspect ratio styles
|
||||
const aspectRatioStyle = $derived(
|
||||
!aspectRatio
|
||||
? 'aspect-ratio: 16/9;'
|
||||
: (() => {
|
||||
const [width, height] = aspectRatio.split(':').map(Number)
|
||||
return width && height
|
||||
? `aspect-ratio: ${width}/${height};`
|
||||
: 'aspect-ratio: 16/9;'
|
||||
})()
|
||||
)
|
||||
</script>
|
||||
|
||||
<div class="image-picker">
|
||||
<label class="input-label">
|
||||
{label}
|
||||
{#if required}
|
||||
<span class="required">*</span>
|
||||
{/if}
|
||||
</label>
|
||||
|
||||
<!-- Image Preview Area -->
|
||||
<div
|
||||
class="image-preview-container"
|
||||
class:has-image={hasImage}
|
||||
class:has-error={error}
|
||||
style={aspectRatioStyle}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={openModal}
|
||||
onkeydown={(e) => e.key === 'Enter' && openModal()}
|
||||
onmouseenter={() => isHovering = true}
|
||||
onmouseleave={() => isHovering = false}
|
||||
>
|
||||
{#if hasImage && value}
|
||||
<!-- Image Display -->
|
||||
<img
|
||||
src={value.url}
|
||||
alt={value.filename}
|
||||
class="preview-image"
|
||||
/>
|
||||
|
||||
<!-- Hover Overlay -->
|
||||
{#if isHovering}
|
||||
<div class="image-overlay">
|
||||
<div class="overlay-actions">
|
||||
<Button variant="primary" onclick={openModal}>
|
||||
<svg
|
||||
slot="icon"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
Change
|
||||
</Button>
|
||||
<Button variant="ghost" onclick={handleClear}>
|
||||
<svg
|
||||
slot="icon"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M3 6h18m-2 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<!-- 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>
|
||||
</div>
|
||||
<p class="empty-text">{placeholder}</p>
|
||||
<Button variant="ghost" onclick={openModal}>
|
||||
<svg
|
||||
slot="icon"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12 5v14m-7-7h14"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
Select Image
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Image Details -->
|
||||
{#if hasImage && value}
|
||||
<div class="image-details">
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Filename:</span>
|
||||
<span class="detail-value">{value.filename}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Size:</span>
|
||||
<span class="detail-value">{formatFileSize(value.size)}</span>
|
||||
</div>
|
||||
{#if showDimensions && value.width && value.height}
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Dimensions:</span>
|
||||
<span class="detail-value">{value.width} × {value.height} px</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Error Message -->
|
||||
{#if error}
|
||||
<p class="error-message">{error}</p>
|
||||
{/if}
|
||||
|
||||
<!-- Media Library Modal -->
|
||||
<MediaLibraryModal
|
||||
bind:isOpen={showModal}
|
||||
mode="single"
|
||||
fileType="image"
|
||||
{selectedIds}
|
||||
title="Select Image"
|
||||
confirmText="Select Image"
|
||||
onselect={handleImageSelect}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.image-picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit;
|
||||
}
|
||||
|
||||
.input-label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: $grey-20;
|
||||
|
||||
.required {
|
||||
color: $red-60;
|
||||
margin-left: $unit-half;
|
||||
}
|
||||
}
|
||||
|
||||
.image-preview-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
border: 2px dashed $grey-80;
|
||||
border-radius: $card-corner-radius;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
background-color: $grey-95;
|
||||
|
||||
&:hover {
|
||||
border-color: $grey-60;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: $blue-60;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
&.has-image {
|
||||
border-style: solid;
|
||||
border-color: $grey-80;
|
||||
background-color: transparent;
|
||||
|
||||
&:hover {
|
||||
border-color: $blue-60;
|
||||
}
|
||||
}
|
||||
|
||||
&.has-error {
|
||||
border-color: $red-60;
|
||||
|
||||
&:focus {
|
||||
border-color: $red-60;
|
||||
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.image-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
animation: fadeIn 0.2s ease forwards;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.overlay-actions {
|
||||
display: flex;
|
||||
gap: $unit-2x;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: $unit-4x;
|
||||
text-align: center;
|
||||
height: 100%;
|
||||
min-height: 200px;
|
||||
gap: $unit-2x;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
color: $grey-60;
|
||||
margin-bottom: $unit;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
color: $grey-40;
|
||||
margin-bottom: $unit;
|
||||
}
|
||||
|
||||
.image-details {
|
||||
padding: $unit-2x;
|
||||
background-color: $grey-95;
|
||||
border-radius: $card-corner-radius;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-half;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-weight: 500;
|
||||
color: $grey-30;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
color: $grey-10;
|
||||
text-align: right;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: $red-60;
|
||||
}
|
||||
|
||||
// Responsive adjustments
|
||||
@media (max-width: 640px) {
|
||||
.empty-state {
|
||||
padding: $unit-3x;
|
||||
min-height: 150px;
|
||||
}
|
||||
|
||||
.empty-icon svg {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.overlay-actions {
|
||||
flex-direction: column;
|
||||
gap: $unit;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: $unit-half;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -4,19 +4,19 @@
|
|||
import Upload from 'lucide-svelte/icons/upload'
|
||||
import Link from 'lucide-svelte/icons/link'
|
||||
import { NodeViewWrapper } from 'svelte-tiptap'
|
||||
|
||||
|
||||
const { editor }: NodeViewProps = $props()
|
||||
|
||||
|
||||
let fileInput: HTMLInputElement
|
||||
let isDragging = $state(false)
|
||||
|
||||
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (!editor.isEditable) return
|
||||
e.preventDefault()
|
||||
|
||||
|
||||
// Show options: upload file or enter URL
|
||||
const choice = confirm('Click OK to upload a file, or Cancel to enter a URL')
|
||||
|
||||
|
||||
if (choice) {
|
||||
// Upload file
|
||||
fileInput?.click()
|
||||
|
|
@ -28,7 +28,7 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function handleFileSelect(e: Event) {
|
||||
const target = e.target as HTMLInputElement
|
||||
const file = target.files?.[0]
|
||||
|
|
@ -38,30 +38,30 @@
|
|||
// Reset input
|
||||
target.value = ''
|
||||
}
|
||||
|
||||
|
||||
async function uploadFile(file: File) {
|
||||
// Check file type
|
||||
if (!file.type.startsWith('image/')) {
|
||||
alert('Please select an image file')
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Check file size (2MB max)
|
||||
const filesize = file.size / 1024 / 1024
|
||||
if (filesize > 2) {
|
||||
alert(`Image too large! File size: ${filesize.toFixed(2)} MB (max 2MB)`)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
const auth = localStorage.getItem('admin_auth')
|
||||
if (!auth) {
|
||||
throw new Error('Not authenticated')
|
||||
}
|
||||
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
|
||||
const response = await fetch('/api/media/upload', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
|
|
@ -69,47 +69,51 @@
|
|||
},
|
||||
body: formData
|
||||
})
|
||||
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Upload failed')
|
||||
}
|
||||
|
||||
|
||||
const media = await response.json()
|
||||
|
||||
|
||||
// Insert the uploaded image with reasonable default width
|
||||
const displayWidth = media.width && media.width > 600 ? 600 : media.width
|
||||
|
||||
editor.chain().focus().setImage({
|
||||
src: media.url,
|
||||
alt: media.filename || '',
|
||||
width: displayWidth,
|
||||
height: media.height,
|
||||
align: 'center'
|
||||
}).run()
|
||||
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.setImage({
|
||||
src: media.url,
|
||||
alt: media.filename || '',
|
||||
width: displayWidth,
|
||||
height: media.height,
|
||||
align: 'center'
|
||||
})
|
||||
.run()
|
||||
} catch (error) {
|
||||
console.error('Image upload failed:', error)
|
||||
alert('Failed to upload image. Please try again.')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Drag and drop handlers
|
||||
function handleDragOver(e: DragEvent) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
isDragging = true
|
||||
}
|
||||
|
||||
|
||||
function handleDragLeave(e: DragEvent) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
isDragging = false
|
||||
}
|
||||
|
||||
|
||||
async function handleDrop(e: DragEvent) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
isDragging = false
|
||||
|
||||
|
||||
const file = e.dataTransfer?.files[0]
|
||||
if (file && file.type.startsWith('image/')) {
|
||||
await uploadFile(file)
|
||||
|
|
@ -125,7 +129,7 @@
|
|||
onchange={handleFileSelect}
|
||||
style="display: none;"
|
||||
/>
|
||||
|
||||
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<span
|
||||
class="edra-media-placeholder-content {isDragging ? 'dragging' : ''}"
|
||||
|
|
@ -141,9 +145,7 @@
|
|||
<span class="edra-media-placeholder-text">
|
||||
{isDragging ? 'Drop image here' : 'Click to upload or drag & drop'}
|
||||
</span>
|
||||
<span class="edra-media-placeholder-subtext">
|
||||
or paste from clipboard
|
||||
</span>
|
||||
<span class="edra-media-placeholder-subtext"> or paste from clipboard </span>
|
||||
</span>
|
||||
</NodeViewWrapper>
|
||||
|
||||
|
|
@ -151,15 +153,15 @@
|
|||
.edra-media-placeholder-content {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
|
||||
.edra-media-placeholder-content.dragging {
|
||||
background-color: rgba(59, 130, 246, 0.1);
|
||||
border-color: rgb(59, 130, 246);
|
||||
}
|
||||
|
||||
|
||||
.edra-media-placeholder-subtext {
|
||||
font-size: 0.875em;
|
||||
opacity: 0.7;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
|
|
|||
783
src/lib/components/admin/ImageUploader.svelte
Normal file
783
src/lib/components/admin/ImageUploader.svelte
Normal file
|
|
@ -0,0 +1,783 @@
|
|||
<script lang="ts">
|
||||
import type { Media } from '@prisma/client'
|
||||
import Button from './Button.svelte'
|
||||
import Input from './Input.svelte'
|
||||
import SmartImage from '../SmartImage.svelte'
|
||||
import MediaLibraryModal from './MediaLibraryModal.svelte'
|
||||
import { authenticatedFetch } from '$lib/admin-auth'
|
||||
import RefreshIcon from '$icons/refresh.svg?component'
|
||||
|
||||
interface Props {
|
||||
label: string
|
||||
value?: Media | null
|
||||
onUpload: (media: Media) => void
|
||||
aspectRatio?: string // e.g., "16:9", "1:1"
|
||||
required?: boolean
|
||||
error?: string
|
||||
allowAltText?: boolean
|
||||
maxFileSize?: number // MB limit
|
||||
placeholder?: string
|
||||
helpText?: string
|
||||
showBrowseLibrary?: boolean // Show secondary "Browse Library" button
|
||||
compact?: boolean // Use compact layout with smaller preview and side-by-side alt text
|
||||
}
|
||||
|
||||
let {
|
||||
label,
|
||||
value = $bindable(),
|
||||
onUpload,
|
||||
aspectRatio,
|
||||
required = false,
|
||||
error,
|
||||
allowAltText = true,
|
||||
maxFileSize = 10,
|
||||
placeholder = 'Drag and drop an image here, or click to browse',
|
||||
helpText,
|
||||
showBrowseLibrary = false,
|
||||
compact = false
|
||||
}: Props = $props()
|
||||
|
||||
// State
|
||||
let isUploading = $state(false)
|
||||
let uploadProgress = $state(0)
|
||||
let uploadError = $state<string | null>(null)
|
||||
let isDragOver = $state(false)
|
||||
let fileInputElement: HTMLInputElement
|
||||
let altTextValue = $state(value?.altText || '')
|
||||
let descriptionValue = $state(value?.description || '')
|
||||
let isMediaLibraryOpen = $state(false)
|
||||
|
||||
// Computed properties
|
||||
const hasValue = $derived(!!value)
|
||||
const aspectRatioStyle = $derived(() => {
|
||||
if (!aspectRatio) return ''
|
||||
const [w, h] = aspectRatio.split(':').map(Number)
|
||||
const ratio = (h / w) * 100
|
||||
return `aspect-ratio: ${w}/${h}; padding-bottom: ${ratio}%;`
|
||||
})
|
||||
|
||||
// File validation
|
||||
function validateFile(file: File): string | null {
|
||||
// Check file type
|
||||
if (!file.type.startsWith('image/')) {
|
||||
return 'Please select an image file'
|
||||
}
|
||||
|
||||
// Check file size
|
||||
const sizeMB = file.size / 1024 / 1024
|
||||
if (sizeMB > maxFileSize) {
|
||||
return `File size must be less than ${maxFileSize}MB`
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// Upload file to server
|
||||
async function uploadFile(file: File): Promise<Media> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
if (allowAltText && altTextValue.trim()) {
|
||||
formData.append('altText', altTextValue.trim())
|
||||
}
|
||||
|
||||
if (allowAltText && descriptionValue.trim()) {
|
||||
formData.append('description', descriptionValue.trim())
|
||||
}
|
||||
|
||||
const response = await authenticatedFetch('/api/media/upload', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || 'Upload failed')
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
// Handle file selection/drop
|
||||
async function handleFiles(files: FileList) {
|
||||
if (files.length === 0) return
|
||||
|
||||
const file = files[0]
|
||||
const validationError = validateFile(file)
|
||||
|
||||
if (validationError) {
|
||||
uploadError = validationError
|
||||
return
|
||||
}
|
||||
|
||||
uploadError = null
|
||||
isUploading = true
|
||||
uploadProgress = 0
|
||||
|
||||
try {
|
||||
// Simulate progress for user feedback
|
||||
const progressInterval = setInterval(() => {
|
||||
if (uploadProgress < 90) {
|
||||
uploadProgress += Math.random() * 10
|
||||
}
|
||||
}, 100)
|
||||
|
||||
const uploadedMedia = await uploadFile(file)
|
||||
|
||||
clearInterval(progressInterval)
|
||||
uploadProgress = 100
|
||||
|
||||
// Brief delay to show completion
|
||||
setTimeout(() => {
|
||||
value = uploadedMedia
|
||||
altTextValue = uploadedMedia.altText || ''
|
||||
descriptionValue = uploadedMedia.description || ''
|
||||
onUpload(uploadedMedia)
|
||||
isUploading = false
|
||||
uploadProgress = 0
|
||||
}, 500)
|
||||
|
||||
} catch (err) {
|
||||
isUploading = false
|
||||
uploadProgress = 0
|
||||
uploadError = err instanceof Error ? err.message : 'Upload failed'
|
||||
}
|
||||
}
|
||||
|
||||
// Drag and drop handlers
|
||||
function handleDragOver(event: DragEvent) {
|
||||
event.preventDefault()
|
||||
isDragOver = true
|
||||
}
|
||||
|
||||
function handleDragLeave(event: DragEvent) {
|
||||
event.preventDefault()
|
||||
isDragOver = false
|
||||
}
|
||||
|
||||
function handleDrop(event: DragEvent) {
|
||||
event.preventDefault()
|
||||
isDragOver = false
|
||||
|
||||
const files = event.dataTransfer?.files
|
||||
if (files) {
|
||||
handleFiles(files)
|
||||
}
|
||||
}
|
||||
|
||||
// Click to browse handler
|
||||
function handleBrowseClick() {
|
||||
fileInputElement?.click()
|
||||
}
|
||||
|
||||
function handleFileInputChange(event: Event) {
|
||||
const target = event.target as HTMLInputElement
|
||||
if (target.files) {
|
||||
handleFiles(target.files)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove uploaded image
|
||||
function handleRemove() {
|
||||
value = null
|
||||
altTextValue = ''
|
||||
descriptionValue = ''
|
||||
uploadError = null
|
||||
}
|
||||
|
||||
// Update alt text on server
|
||||
async function handleAltTextChange() {
|
||||
if (!value) return
|
||||
|
||||
try {
|
||||
const response = await authenticatedFetch(`/api/media/${value.id}/metadata`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
altText: altTextValue.trim() || null
|
||||
})
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const updatedData = await response.json()
|
||||
value = { ...value, altText: updatedData.altText, updatedAt: updatedData.updatedAt }
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update alt text:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDescriptionChange() {
|
||||
if (!value) return
|
||||
|
||||
try {
|
||||
const response = await authenticatedFetch(`/api/media/${value.id}/metadata`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
description: descriptionValue.trim() || null
|
||||
})
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const updatedData = await response.json()
|
||||
value = { ...value, description: updatedData.description, updatedAt: updatedData.updatedAt }
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update description:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Browse library handler
|
||||
function handleBrowseLibrary() {
|
||||
isMediaLibraryOpen = true
|
||||
}
|
||||
|
||||
function handleMediaSelect(selectedMedia: Media | Media[]) {
|
||||
// Since this is single mode, selectedMedia will be a single Media object
|
||||
const media = selectedMedia as Media
|
||||
value = media
|
||||
altTextValue = media.altText || ''
|
||||
descriptionValue = media.description || ''
|
||||
onUpload(media)
|
||||
}
|
||||
|
||||
function handleMediaLibraryClose() {
|
||||
isMediaLibraryOpen = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="image-uploader" class:compact>
|
||||
<!-- Label -->
|
||||
<label class="uploader-label">
|
||||
{label}
|
||||
{#if required}
|
||||
<span class="required">*</span>
|
||||
{/if}
|
||||
</label>
|
||||
|
||||
{#if helpText}
|
||||
<p class="help-text">{helpText}</p>
|
||||
{/if}
|
||||
|
||||
<!-- Upload Area or Preview -->
|
||||
<div class="upload-container">
|
||||
{#if hasValue && !isUploading}
|
||||
{#if compact}
|
||||
<!-- Compact Layout: Image and metadata side-by-side -->
|
||||
<div class="compact-preview">
|
||||
<div class="compact-image">
|
||||
<SmartImage
|
||||
media={value}
|
||||
alt={value?.altText || value?.filename || 'Uploaded image'}
|
||||
containerWidth={100}
|
||||
loading="eager"
|
||||
aspectRatio={aspectRatio}
|
||||
class="preview-image"
|
||||
/>
|
||||
|
||||
<!-- Overlay with actions -->
|
||||
<div class="preview-overlay">
|
||||
<div class="preview-actions">
|
||||
<Button variant="overlay" size="small" onclick={handleBrowseClick}>
|
||||
<RefreshIcon slot="icon" width="12" height="12" />
|
||||
</Button>
|
||||
|
||||
<Button variant="overlay" size="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>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="compact-info">
|
||||
<!-- Alt Text Input in compact mode -->
|
||||
{#if allowAltText}
|
||||
<div class="compact-metadata">
|
||||
<Input
|
||||
type="text"
|
||||
label="Alt Text"
|
||||
bind:value={altTextValue}
|
||||
placeholder="Describe this image for screen readers"
|
||||
size="small"
|
||||
onblur={handleAltTextChange}
|
||||
/>
|
||||
|
||||
<Input
|
||||
type="textarea"
|
||||
label="Description (Optional)"
|
||||
bind:value={descriptionValue}
|
||||
placeholder="Additional description or caption"
|
||||
rows={2}
|
||||
size="small"
|
||||
onblur={handleDescriptionChange}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Standard Layout: Image preview -->
|
||||
<div class="image-preview" style={aspectRatioStyle}>
|
||||
<SmartImage
|
||||
media={value}
|
||||
alt={value?.altText || value?.filename || 'Uploaded image'}
|
||||
containerWidth={800}
|
||||
loading="eager"
|
||||
aspectRatio={aspectRatio}
|
||||
class="preview-image"
|
||||
/>
|
||||
|
||||
<!-- Overlay with actions -->
|
||||
<div class="preview-overlay">
|
||||
<div class="preview-actions">
|
||||
<Button variant="overlay" size="small" onclick={handleBrowseClick}>
|
||||
<RefreshIcon slot="icon" width="16" height="16" />
|
||||
Replace
|
||||
</Button>
|
||||
|
||||
<Button variant="overlay" size="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>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- File Info -->
|
||||
<div class="file-info">
|
||||
<p class="filename">{value?.originalName || value?.filename}</p>
|
||||
<p class="file-meta">
|
||||
{Math.round((value?.size || 0) / 1024)} KB
|
||||
{#if value?.width && value?.height}
|
||||
• {value.width}×{value.height}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{:else}
|
||||
<!-- Upload Drop Zone -->
|
||||
<div
|
||||
class="drop-zone"
|
||||
class:drag-over={isDragOver}
|
||||
class:uploading={isUploading}
|
||||
class:has-error={!!uploadError}
|
||||
style={aspectRatioStyle}
|
||||
ondragover={handleDragOver}
|
||||
ondragleave={handleDragLeave}
|
||||
ondrop={handleDrop}
|
||||
onclick={handleBrowseClick}
|
||||
>
|
||||
{#if isUploading}
|
||||
<!-- Upload Progress -->
|
||||
<div class="upload-progress">
|
||||
<svg class="upload-spinner" width="24" height="24" viewBox="0 0 24 24">
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
fill="none"
|
||||
stroke-dasharray="60"
|
||||
stroke-dashoffset="60"
|
||||
stroke-linecap="round"
|
||||
>
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
type="rotate"
|
||||
from="0 12 12"
|
||||
to="360 12 12"
|
||||
dur="1s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</circle>
|
||||
</svg>
|
||||
<p class="upload-text">Uploading... {Math.round(uploadProgress)}%</p>
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" style="width: {uploadProgress}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
{: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>
|
||||
<p class="upload-main-text">{placeholder}</p>
|
||||
<p class="upload-sub-text">
|
||||
Supports JPG, PNG, GIF up to {maxFileSize}MB
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
{#if !hasValue && !isUploading}
|
||||
<div class="action-buttons">
|
||||
<Button variant="primary" onclick={handleBrowseClick}>
|
||||
Choose File
|
||||
</Button>
|
||||
|
||||
{#if showBrowseLibrary}
|
||||
<Button variant="ghost" onclick={handleBrowseLibrary}>
|
||||
Browse Library
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Alt Text Input (only in standard mode, compact mode has it inline) -->
|
||||
{#if allowAltText && hasValue && !compact}
|
||||
<div class="metadata-section">
|
||||
<Input
|
||||
type="text"
|
||||
label="Alt Text"
|
||||
bind:value={altTextValue}
|
||||
placeholder="Describe this image for screen readers"
|
||||
helpText="Help make your content accessible. Describe what's in the image."
|
||||
onblur={handleAltTextChange}
|
||||
/>
|
||||
|
||||
<Input
|
||||
type="textarea"
|
||||
label="Description (Optional)"
|
||||
bind:value={descriptionValue}
|
||||
placeholder="Additional description or caption"
|
||||
rows={2}
|
||||
onblur={handleDescriptionChange}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Error Message -->
|
||||
{#if error || uploadError}
|
||||
<p class="error-message">{error || uploadError}</p>
|
||||
{/if}
|
||||
|
||||
<!-- Hidden File Input -->
|
||||
<input
|
||||
bind:this={fileInputElement}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style="display: none;"
|
||||
onchange={handleFileInputChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Media Library Modal -->
|
||||
<MediaLibraryModal
|
||||
bind:isOpen={isMediaLibraryOpen}
|
||||
mode="single"
|
||||
fileType="image"
|
||||
title="Select Image"
|
||||
confirmText="Select Image"
|
||||
onSelect={handleMediaSelect}
|
||||
onClose={handleMediaLibraryClose}
|
||||
/>
|
||||
|
||||
<style lang="scss">
|
||||
.image-uploader {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-2x;
|
||||
|
||||
&.compact {
|
||||
gap: $unit;
|
||||
}
|
||||
}
|
||||
|
||||
.uploader-label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: $grey-20;
|
||||
|
||||
.required {
|
||||
color: $red-60;
|
||||
margin-left: $unit-half;
|
||||
}
|
||||
}
|
||||
|
||||
.help-text {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
color: $grey-40;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.upload-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
// Drop Zone Styles
|
||||
.drop-zone {
|
||||
border: 2px dashed $grey-80;
|
||||
border-radius: $card-corner-radius;
|
||||
background-color: $grey-97;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
min-height: 200px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&:hover {
|
||||
border-color: $blue-60;
|
||||
background-color: rgba($blue-60, 0.02);
|
||||
}
|
||||
|
||||
&.drag-over {
|
||||
border-color: $blue-60;
|
||||
background-color: rgba($blue-60, 0.05);
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
&.uploading {
|
||||
cursor: default;
|
||||
border-color: $blue-60;
|
||||
}
|
||||
|
||||
&.has-error {
|
||||
border-color: $red-60;
|
||||
background-color: rgba($red-60, 0.02);
|
||||
}
|
||||
}
|
||||
|
||||
.upload-prompt {
|
||||
text-align: center;
|
||||
padding: $unit-4x;
|
||||
|
||||
.upload-icon {
|
||||
color: $grey-50;
|
||||
margin-bottom: $unit-2x;
|
||||
}
|
||||
|
||||
.upload-main-text {
|
||||
margin: 0 0 $unit 0;
|
||||
font-size: 0.875rem;
|
||||
color: $grey-30;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.upload-sub-text {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: $grey-50;
|
||||
}
|
||||
}
|
||||
|
||||
.upload-progress {
|
||||
text-align: center;
|
||||
padding: $unit-4x;
|
||||
|
||||
.upload-spinner {
|
||||
color: $blue-60;
|
||||
margin-bottom: $unit-2x;
|
||||
}
|
||||
|
||||
.upload-text {
|
||||
margin: 0 0 $unit-2x 0;
|
||||
font-size: 0.875rem;
|
||||
color: $grey-30;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 200px;
|
||||
height: 4px;
|
||||
background-color: $grey-90;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
margin: 0 auto;
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background-color: $blue-60;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Image Preview Styles
|
||||
.image-preview {
|
||||
position: relative;
|
||||
border-radius: $card-corner-radius;
|
||||
overflow: hidden;
|
||||
background-color: $grey-95;
|
||||
min-height: 200px;
|
||||
|
||||
:global(.preview-image) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.preview-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
&:hover .preview-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.preview-actions {
|
||||
display: flex;
|
||||
gap: $unit;
|
||||
}
|
||||
}
|
||||
|
||||
.file-info {
|
||||
margin-top: $unit-2x;
|
||||
|
||||
.filename {
|
||||
margin: 0 0 $unit-half 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: $grey-10;
|
||||
}
|
||||
|
||||
.file-meta {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: $grey-40;
|
||||
}
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: $unit-2x;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.metadata-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-2x;
|
||||
padding: $unit-3x;
|
||||
background-color: $grey-97;
|
||||
border-radius: $card-corner-radius;
|
||||
border: 1px solid $grey-90;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: $red-60;
|
||||
padding: $unit;
|
||||
background-color: rgba($red-60, 0.05);
|
||||
border-radius: $card-corner-radius;
|
||||
border: 1px solid rgba($red-60, 0.2);
|
||||
}
|
||||
|
||||
// Compact layout styles
|
||||
.compact-preview {
|
||||
display: flex;
|
||||
gap: $unit-3x;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.compact-image {
|
||||
position: relative;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
flex-shrink: 0;
|
||||
border-radius: $card-corner-radius;
|
||||
overflow: hidden;
|
||||
background-color: $grey-95;
|
||||
border: 1px solid $grey-90;
|
||||
|
||||
:global(.preview-image) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
padding: $unit-3x;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.preview-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
&:hover .preview-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.preview-actions {
|
||||
display: flex;
|
||||
gap: $unit-half;
|
||||
}
|
||||
}
|
||||
|
||||
.compact-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.compact-metadata {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-2x;
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive adjustments
|
||||
@media (max-width: 640px) {
|
||||
.upload-prompt {
|
||||
padding: $unit-3x;
|
||||
|
||||
.upload-main-text {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.preview-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
464
src/lib/components/admin/Input.svelte
Normal file
464
src/lib/components/admin/Input.svelte
Normal file
|
|
@ -0,0 +1,464 @@
|
|||
<script lang="ts">
|
||||
import type { HTMLInputAttributes, HTMLTextareaAttributes } from 'svelte/elements'
|
||||
|
||||
// Type helpers for different input elements
|
||||
type InputProps = HTMLInputAttributes & {
|
||||
type?: 'text' | 'email' | 'password' | 'url' | 'search' | 'number' | 'tel' | 'date' | 'time' | 'color'
|
||||
}
|
||||
|
||||
type TextareaProps = HTMLTextareaAttributes & {
|
||||
type: 'textarea'
|
||||
rows?: number
|
||||
autoResize?: boolean
|
||||
}
|
||||
|
||||
type Props = (InputProps | TextareaProps) & {
|
||||
label?: string
|
||||
error?: string
|
||||
helpText?: string
|
||||
size?: 'small' | 'medium' | 'large'
|
||||
fullWidth?: boolean
|
||||
required?: boolean
|
||||
class?: string
|
||||
wrapperClass?: string
|
||||
inputClass?: string
|
||||
prefixIcon?: boolean
|
||||
suffixIcon?: boolean
|
||||
showCharCount?: boolean
|
||||
maxLength?: number
|
||||
colorSwatch?: boolean // Show color swatch based on input value
|
||||
}
|
||||
|
||||
let {
|
||||
label,
|
||||
error,
|
||||
helpText,
|
||||
size = 'medium',
|
||||
fullWidth = true,
|
||||
required = false,
|
||||
disabled = false,
|
||||
readonly = false,
|
||||
type = 'text',
|
||||
value = $bindable(''),
|
||||
class: className = '',
|
||||
wrapperClass = '',
|
||||
inputClass = '',
|
||||
prefixIcon = false,
|
||||
suffixIcon = false,
|
||||
showCharCount = false,
|
||||
maxLength,
|
||||
colorSwatch = false,
|
||||
id = `input-${Math.random().toString(36).substr(2, 9)}`,
|
||||
...restProps
|
||||
}: Props = $props()
|
||||
|
||||
// For textarea auto-resize
|
||||
let textareaElement: HTMLTextAreaElement | undefined = $state()
|
||||
let charCount = $derived(String(value).length)
|
||||
let charsRemaining = $derived(maxLength ? maxLength - charCount : 0)
|
||||
|
||||
// Color swatch validation and display
|
||||
const isValidHexColor = $derived(() => {
|
||||
if (!colorSwatch || !value) return false
|
||||
const hexRegex = /^#[0-9A-Fa-f]{6}$/
|
||||
return hexRegex.test(String(value))
|
||||
})
|
||||
|
||||
// Color picker functionality
|
||||
let colorPickerInput: HTMLInputElement
|
||||
|
||||
function handleColorSwatchClick() {
|
||||
if (colorPickerInput) {
|
||||
colorPickerInput.click()
|
||||
}
|
||||
}
|
||||
|
||||
function handleColorPickerChange(event: Event) {
|
||||
const target = event.target as HTMLInputElement
|
||||
if (target.value) {
|
||||
value = target.value.toUpperCase()
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-resize textarea
|
||||
$effect(() => {
|
||||
if (type === 'textarea' && textareaElement && isTextarea(restProps) && restProps.autoResize) {
|
||||
// Reset height to auto to get the correct scrollHeight
|
||||
textareaElement.style.height = 'auto'
|
||||
// Set the height to match content
|
||||
textareaElement.style.height = textareaElement.scrollHeight + 'px'
|
||||
}
|
||||
})
|
||||
|
||||
// Compute classes
|
||||
const wrapperClasses = $derived(() => {
|
||||
const classes = ['input-wrapper']
|
||||
if (size) classes.push(`input-wrapper-${size}`)
|
||||
if (fullWidth) classes.push('full-width')
|
||||
if (error) classes.push('has-error')
|
||||
if (disabled) classes.push('is-disabled')
|
||||
if (prefixIcon) classes.push('has-prefix-icon')
|
||||
if (suffixIcon) classes.push('has-suffix-icon')
|
||||
if (colorSwatch) classes.push('has-color-swatch')
|
||||
if (type === 'textarea' && isTextarea(restProps) && restProps.autoResize) classes.push('has-auto-resize')
|
||||
if (wrapperClass) classes.push(wrapperClass)
|
||||
if (className) classes.push(className)
|
||||
return classes.join(' ')
|
||||
})
|
||||
|
||||
const inputClasses = $derived(() => {
|
||||
const classes = ['input']
|
||||
classes.push(`input-${size}`)
|
||||
if (inputClass) classes.push(inputClass)
|
||||
return classes.join(' ')
|
||||
})
|
||||
|
||||
// Type guard for textarea props
|
||||
function isTextarea(props: Props): props is TextareaProps {
|
||||
return props.type === 'textarea'
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class={wrapperClasses()}>
|
||||
{#if label}
|
||||
<label for={id} class="input-label">
|
||||
{label}
|
||||
{#if required}
|
||||
<span class="required-indicator">*</span>
|
||||
{/if}
|
||||
</label>
|
||||
{/if}
|
||||
|
||||
<div class="input-container">
|
||||
{#if prefixIcon}
|
||||
<span class="input-icon prefix-icon">
|
||||
<slot name="prefix" />
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if colorSwatch && isValidHexColor}
|
||||
<span
|
||||
class="color-swatch"
|
||||
style="background-color: {value}"
|
||||
onclick={handleColorSwatchClick}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="Open color picker"
|
||||
></span>
|
||||
{/if}
|
||||
|
||||
{#if type === 'textarea' && isTextarea(restProps)}
|
||||
<textarea
|
||||
bind:this={textareaElement}
|
||||
bind:value
|
||||
{id}
|
||||
{disabled}
|
||||
{readonly}
|
||||
{required}
|
||||
{maxLength}
|
||||
class={inputClasses()}
|
||||
rows={restProps.rows || 3}
|
||||
{...restProps}
|
||||
/>
|
||||
{:else}
|
||||
<input
|
||||
bind:value
|
||||
{id}
|
||||
{type}
|
||||
{disabled}
|
||||
{readonly}
|
||||
{required}
|
||||
{maxLength}
|
||||
class={inputClasses()}
|
||||
{...restProps}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if suffixIcon}
|
||||
<span class="input-icon suffix-icon">
|
||||
<slot name="suffix" />
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if colorSwatch}
|
||||
<input
|
||||
bind:this={colorPickerInput}
|
||||
type="color"
|
||||
value={isValidHexColor ? String(value) : '#000000'}
|
||||
oninput={handleColorPickerChange}
|
||||
onchange={handleColorPickerChange}
|
||||
style="position: absolute; visibility: hidden; pointer-events: none;"
|
||||
tabindex="-1"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if (error || helpText || showCharCount) && !disabled}
|
||||
<div class="input-footer">
|
||||
{#if error}
|
||||
<span class="input-error">{error}</span>
|
||||
{:else if helpText}
|
||||
<span class="input-help">{helpText}</span>
|
||||
{/if}
|
||||
|
||||
{#if showCharCount && maxLength}
|
||||
<span class="char-count" class:warning={charsRemaining < maxLength * 0.1} class:error={charsRemaining < 0}>
|
||||
{charsRemaining}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@import '$styles/variables.scss';
|
||||
|
||||
// Wrapper styles
|
||||
.input-wrapper {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
|
||||
&.full-width {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&.has-error {
|
||||
.input {
|
||||
border-color: $red-50;
|
||||
|
||||
&:focus {
|
||||
border-color: $red-50;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.is-disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&.has-color-swatch {
|
||||
.input {
|
||||
padding-left: 36px; // Make room for color swatch (20px + 8px margin + 8px padding)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Label styles
|
||||
.input-label {
|
||||
display: block;
|
||||
margin-bottom: $unit;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: $grey-20;
|
||||
}
|
||||
|
||||
.required-indicator {
|
||||
color: $red-50;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
// Container for input and icons
|
||||
.input-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
// Color swatch styles
|
||||
.color-swatch {
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
z-index: 1;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
// Input and textarea styles
|
||||
.input {
|
||||
width: 100%;
|
||||
font-size: 14px;
|
||||
border: 1px solid $grey-80;
|
||||
border-radius: 6px;
|
||||
background-color: white;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&::placeholder {
|
||||
color: $grey-50;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: $primary-color;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background-color: $grey-95;
|
||||
cursor: not-allowed;
|
||||
color: $grey-40;
|
||||
}
|
||||
|
||||
&:read-only {
|
||||
background-color: $grey-97;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
// Size variations
|
||||
.input-small {
|
||||
padding: $unit calc($unit * 1.5);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.input-medium {
|
||||
padding: calc($unit * 1.5) $unit-2x;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.input-large {
|
||||
padding: $unit-2x $unit-3x;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
// Icon adjustments
|
||||
.has-prefix-icon .input {
|
||||
padding-left: calc($unit-2x + 24px);
|
||||
}
|
||||
|
||||
.has-suffix-icon .input {
|
||||
padding-right: calc($unit-2x + 24px);
|
||||
}
|
||||
|
||||
.input-icon {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: $grey-40;
|
||||
pointer-events: none;
|
||||
|
||||
&.prefix-icon {
|
||||
left: $unit-2x;
|
||||
}
|
||||
|
||||
&.suffix-icon {
|
||||
right: $unit-2x;
|
||||
}
|
||||
|
||||
:global(svg) {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
// Textarea specific
|
||||
textarea.input {
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
padding-top: calc($unit * 1.5);
|
||||
padding-bottom: calc($unit * 1.5);
|
||||
line-height: 1.5;
|
||||
overflow-y: hidden; // Important for auto-resize
|
||||
|
||||
&.input-small {
|
||||
min-height: 60px;
|
||||
padding-top: $unit;
|
||||
padding-bottom: $unit;
|
||||
}
|
||||
|
||||
&.input-large {
|
||||
min-height: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-resizing textarea
|
||||
.has-auto-resize textarea.input {
|
||||
resize: none; // Disable manual resize when auto-resize is enabled
|
||||
}
|
||||
|
||||
// Footer styles
|
||||
.input-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: $unit-half;
|
||||
min-height: 20px;
|
||||
}
|
||||
|
||||
.input-error,
|
||||
.input-help {
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.input-error {
|
||||
color: $red-50;
|
||||
}
|
||||
|
||||
.input-help {
|
||||
color: $grey-40;
|
||||
}
|
||||
|
||||
.char-count {
|
||||
font-size: 12px;
|
||||
color: $grey-50;
|
||||
font-variant-numeric: tabular-nums;
|
||||
margin-left: auto;
|
||||
|
||||
&.warning {
|
||||
color: $universe-color;
|
||||
}
|
||||
|
||||
&.error {
|
||||
color: $red-50;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
// Special input types
|
||||
input[type="color"].input {
|
||||
padding: $unit;
|
||||
cursor: pointer;
|
||||
|
||||
&::-webkit-color-swatch-wrapper {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
&::-webkit-color-swatch {
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
input[type="number"].input {
|
||||
-moz-appearance: textfield;
|
||||
|
||||
&::-webkit-outer-spin-button,
|
||||
&::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Search input
|
||||
input[type="search"].input {
|
||||
&::-webkit-search-decoration,
|
||||
&::-webkit-search-cancel-button {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
535
src/lib/components/admin/MediaDetailsModal.svelte
Normal file
535
src/lib/components/admin/MediaDetailsModal.svelte
Normal file
|
|
@ -0,0 +1,535 @@
|
|||
<script lang="ts">
|
||||
import Modal from './Modal.svelte'
|
||||
import Button from './Button.svelte'
|
||||
import Input from './Input.svelte'
|
||||
import SmartImage from '../SmartImage.svelte'
|
||||
import { authenticatedFetch } from '$lib/admin-auth'
|
||||
import type { Media } from '@prisma/client'
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean
|
||||
media: Media | null
|
||||
onClose: () => void
|
||||
onUpdate: (updatedMedia: Media) => void
|
||||
}
|
||||
|
||||
let {
|
||||
isOpen = $bindable(),
|
||||
media,
|
||||
onClose,
|
||||
onUpdate
|
||||
}: Props = $props()
|
||||
|
||||
// Form state
|
||||
let altText = $state('')
|
||||
let description = $state('')
|
||||
let isSaving = $state(false)
|
||||
let error = $state('')
|
||||
let successMessage = $state('')
|
||||
|
||||
// Initialize form when media changes
|
||||
$effect(() => {
|
||||
if (media) {
|
||||
altText = media.altText || ''
|
||||
description = media.description || ''
|
||||
error = ''
|
||||
successMessage = ''
|
||||
}
|
||||
})
|
||||
|
||||
function handleClose() {
|
||||
altText = ''
|
||||
description = ''
|
||||
error = ''
|
||||
successMessage = ''
|
||||
isOpen = false
|
||||
onClose()
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!media) return
|
||||
|
||||
try {
|
||||
isSaving = true
|
||||
error = ''
|
||||
|
||||
const response = await authenticatedFetch(`/api/media/${media.id}/metadata`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
altText: altText.trim() || null,
|
||||
description: description.trim() || null
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update media')
|
||||
}
|
||||
|
||||
const updatedMedia = await response.json()
|
||||
onUpdate(updatedMedia)
|
||||
successMessage = 'Media updated successfully!'
|
||||
|
||||
// Auto-close after success
|
||||
setTimeout(() => {
|
||||
handleClose()
|
||||
}, 1500)
|
||||
|
||||
} catch (err) {
|
||||
error = 'Failed to update media. Please try again.'
|
||||
console.error('Failed to update media:', err)
|
||||
} finally {
|
||||
isSaving = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!media || !confirm('Are you sure you want to delete this media file? This action cannot be undone.')) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
isSaving = true
|
||||
error = ''
|
||||
|
||||
const response = await authenticatedFetch(`/api/media/${media.id}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete media')
|
||||
}
|
||||
|
||||
// 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)
|
||||
} finally {
|
||||
isSaving = false
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 Bytes'
|
||||
const k = 1024
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
function getFileType(mimeType: string): string {
|
||||
if (mimeType.startsWith('image/')) return 'Image'
|
||||
if (mimeType.startsWith('video/')) return 'Video'
|
||||
if (mimeType.startsWith('audio/')) return 'Audio'
|
||||
if (mimeType.includes('pdf')) return 'PDF'
|
||||
return 'File'
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if media}
|
||||
<Modal bind:isOpen size="large" closeOnBackdrop={!isSaving} closeOnEscape={!isSaving} on:close={handleClose}>
|
||||
<div class="media-details-modal">
|
||||
<!-- Header -->
|
||||
<div class="modal-header">
|
||||
<div class="header-content">
|
||||
<h2>Media Details</h2>
|
||||
<p class="filename">{media.filename}</p>
|
||||
</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>
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="modal-body">
|
||||
<div class="media-preview-section">
|
||||
<!-- Media Preview -->
|
||||
<div class="media-preview">
|
||||
{#if media.mimeType.startsWith('image/')}
|
||||
<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>
|
||||
<span class="file-type">{getFileType(media.mimeType)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- File Info -->
|
||||
<div class="file-info">
|
||||
<div class="info-row">
|
||||
<span class="label">Type:</span>
|
||||
<span class="value">{getFileType(media.mimeType)}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="label">Size:</span>
|
||||
<span class="value">{formatFileSize(media.size)}</span>
|
||||
</div>
|
||||
{#if media.width && media.height}
|
||||
<div class="info-row">
|
||||
<span class="label">Dimensions:</span>
|
||||
<span class="value">{media.width} × {media.height}px</span>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="info-row">
|
||||
<span class="label">Uploaded:</span>
|
||||
<span class="value">{new Date(media.createdAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="label">URL:</span>
|
||||
<div class="url-section">
|
||||
<span class="url-text">{media.url}</span>
|
||||
<Button variant="ghost" size="small" onclick={copyUrl}>
|
||||
Copy
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Form -->
|
||||
<div class="edit-form">
|
||||
<h3>Accessibility & SEO</h3>
|
||||
|
||||
<Input
|
||||
type="text"
|
||||
label="Alt Text"
|
||||
bind:value={altText}
|
||||
placeholder="Describe this image for screen readers"
|
||||
helpText="Help make your content accessible. Describe what's in the image."
|
||||
disabled={isSaving}
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<Input
|
||||
type="textarea"
|
||||
label="Description (Optional)"
|
||||
bind:value={description}
|
||||
placeholder="Additional description or caption"
|
||||
helpText="Optional longer description for context or captions."
|
||||
rows={3}
|
||||
disabled={isSaving}
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<!-- Usage Tracking -->
|
||||
{#if media.usedIn && Array.isArray(media.usedIn) && media.usedIn.length > 0}
|
||||
<div class="usage-section">
|
||||
<h4>Used In</h4>
|
||||
<ul class="usage-list">
|
||||
{#each media.usedIn as usage}
|
||||
<li class="usage-item">
|
||||
<span class="usage-type">{usage.contentType}</span>
|
||||
<span class="usage-field">{usage.fieldName}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="usage-section">
|
||||
<h4>Usage</h4>
|
||||
<p class="no-usage">This media file is not currently used in any content.</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="modal-footer">
|
||||
<div class="footer-left">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onclick={handleDelete}
|
||||
disabled={isSaving}
|
||||
class="delete-button"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="footer-right">
|
||||
{#if error}
|
||||
<span class="error-text">{error}</span>
|
||||
{/if}
|
||||
{#if successMessage}
|
||||
<span class="success-text">{successMessage}</span>
|
||||
{/if}
|
||||
|
||||
<Button variant="ghost" onclick={handleClose} disabled={isSaving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" onclick={handleSave} disabled={isSaving}>
|
||||
{isSaving ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
.media-details-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
max-height: 90vh;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: $unit-4x;
|
||||
border-bottom: 1px solid $grey-90;
|
||||
flex-shrink: 0;
|
||||
|
||||
.header-content {
|
||||
flex: 1;
|
||||
|
||||
h2 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 $unit-half 0;
|
||||
color: $grey-10;
|
||||
}
|
||||
|
||||
.filename {
|
||||
font-size: 0.875rem;
|
||||
color: $grey-40;
|
||||
margin: 0;
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: $unit-4x;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-6x;
|
||||
}
|
||||
|
||||
.media-preview-section {
|
||||
display: grid;
|
||||
grid-template-columns: 300px 1fr;
|
||||
gap: $unit-4x;
|
||||
align-items: start;
|
||||
|
||||
@include breakpoint('tablet') {
|
||||
grid-template-columns: 1fr;
|
||||
gap: $unit-3x;
|
||||
}
|
||||
}
|
||||
|
||||
.media-preview {
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
aspect-ratio: 4/3;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: $grey-95;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
:global(img) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.file-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: $unit-2x;
|
||||
color: $grey-50;
|
||||
|
||||
.file-type {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.file-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-2x;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $unit-2x;
|
||||
|
||||
.label {
|
||||
font-weight: 500;
|
||||
color: $grey-30;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.value {
|
||||
color: $grey-10;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.url-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $unit-2x;
|
||||
flex: 1;
|
||||
|
||||
.url-text {
|
||||
color: $grey-10;
|
||||
font-size: 0.875rem;
|
||||
word-break: break-all;
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.edit-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-4x;
|
||||
|
||||
h3 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: $grey-10;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: $grey-20;
|
||||
}
|
||||
}
|
||||
|
||||
.usage-section {
|
||||
.usage-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: $unit-2x 0 0 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit;
|
||||
}
|
||||
|
||||
.usage-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $unit-2x;
|
||||
padding: $unit-2x;
|
||||
background: $grey-95;
|
||||
border-radius: 8px;
|
||||
|
||||
.usage-type {
|
||||
font-weight: 500;
|
||||
color: $grey-20;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.usage-field {
|
||||
color: $grey-40;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
.no-usage {
|
||||
color: $grey-50;
|
||||
font-style: italic;
|
||||
margin: $unit-2x 0 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: $unit-4x;
|
||||
border-top: 1px solid $grey-90;
|
||||
flex-shrink: 0;
|
||||
|
||||
.footer-left {
|
||||
:global(.delete-button) {
|
||||
color: $red-60;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.footer-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $unit-2x;
|
||||
|
||||
.error-text {
|
||||
color: $red-60;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.success-text {
|
||||
color: #16a34a; // green-600 equivalent
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive adjustments
|
||||
@include breakpoint('phone') {
|
||||
.modal-header {
|
||||
padding: $unit-3x;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: $unit-3x;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: $unit-3x;
|
||||
flex-direction: column;
|
||||
gap: $unit-3x;
|
||||
align-items: stretch;
|
||||
|
||||
.footer-right {
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
394
src/lib/components/admin/MediaInput.svelte
Normal file
394
src/lib/components/admin/MediaInput.svelte
Normal file
|
|
@ -0,0 +1,394 @@
|
|||
<script lang="ts">
|
||||
import Button from './Button.svelte'
|
||||
import MediaLibraryModal from './MediaLibraryModal.svelte'
|
||||
import type { Media } from '@prisma/client'
|
||||
|
||||
interface Props {
|
||||
label: string
|
||||
value?: Media | Media[] | null
|
||||
mode: 'single' | 'multiple'
|
||||
fileType?: 'image' | 'video' | 'all'
|
||||
placeholder?: string
|
||||
required?: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
let {
|
||||
label,
|
||||
value = $bindable(),
|
||||
mode,
|
||||
fileType = 'all',
|
||||
placeholder = mode === 'single' ? 'No file selected' : 'No files selected',
|
||||
required = false,
|
||||
error
|
||||
}: Props = $props()
|
||||
|
||||
let showModal = $state(false)
|
||||
|
||||
function handleMediaSelect(media: Media | Media[]) {
|
||||
value = media
|
||||
showModal = false
|
||||
}
|
||||
|
||||
function handleClear() {
|
||||
if (mode === 'single') {
|
||||
value = null
|
||||
} else {
|
||||
value = []
|
||||
}
|
||||
}
|
||||
|
||||
function openModal() {
|
||||
showModal = true
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
// Computed properties
|
||||
const hasValue = $derived(
|
||||
mode === 'single'
|
||||
? value !== null && value !== undefined
|
||||
: Array.isArray(value) && value.length > 0
|
||||
)
|
||||
|
||||
const displayText = $derived(
|
||||
!hasValue
|
||||
? placeholder
|
||||
: mode === 'single' && value && !Array.isArray(value)
|
||||
? value.filename
|
||||
: mode === 'multiple' && Array.isArray(value)
|
||||
? value.length === 1
|
||||
? `${value.length} file selected`
|
||||
: `${value.length} files selected`
|
||||
: placeholder
|
||||
)
|
||||
|
||||
const selectedIds = $derived(
|
||||
!hasValue
|
||||
? []
|
||||
: mode === 'single' && value && !Array.isArray(value)
|
||||
? [value.id]
|
||||
: mode === 'multiple' && Array.isArray(value)
|
||||
? value.map(item => item.id)
|
||||
: []
|
||||
)
|
||||
|
||||
const modalTitle = $derived(
|
||||
mode === 'single' ? `Select ${fileType === 'image' ? 'Image' : 'Media'}` : `Select ${fileType === 'image' ? 'Images' : 'Media'}`
|
||||
)
|
||||
|
||||
const confirmText = $derived(
|
||||
mode === 'single' ? 'Select' : 'Select Files'
|
||||
)
|
||||
</script>
|
||||
|
||||
<div class="media-input">
|
||||
<label class="input-label">
|
||||
{label}
|
||||
{#if required}
|
||||
<span class="required">*</span>
|
||||
{/if}
|
||||
</label>
|
||||
|
||||
<!-- Selected Media Preview -->
|
||||
{#if hasValue}
|
||||
<div class="selected-media">
|
||||
{#if mode === 'single' && value && !Array.isArray(value)}
|
||||
<div class="media-preview single">
|
||||
<div class="media-thumbnail">
|
||||
{#if value.thumbnailUrl}
|
||||
<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>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="media-info">
|
||||
<p class="filename">{value.filename}</p>
|
||||
<p class="file-meta">
|
||||
{formatFileSize(value.size)}
|
||||
{#if value.width && value.height}
|
||||
• {value.width}×{value.height}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else if mode === 'multiple' && Array.isArray(value) && value.length > 0}
|
||||
<div class="media-preview multiple">
|
||||
<div class="media-grid">
|
||||
{#each value.slice(0, 4) as item}
|
||||
<div class="media-thumbnail">
|
||||
{#if item.thumbnailUrl}
|
||||
<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>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{#if value.length > 4}
|
||||
<div class="media-thumbnail overflow">
|
||||
<div class="overflow-indicator">
|
||||
+{value.length - 4}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="selection-summary">
|
||||
{value.length} file{value.length !== 1 ? 's' : ''} selected
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Input Field -->
|
||||
<div class="input-field" class:has-error={error}>
|
||||
<input
|
||||
type="text"
|
||||
readonly
|
||||
value={displayText}
|
||||
class="media-input-field"
|
||||
class:placeholder={!hasValue}
|
||||
/>
|
||||
<div class="input-actions">
|
||||
<Button variant="ghost" onclick={openModal}>
|
||||
Browse
|
||||
</Button>
|
||||
{#if hasValue}
|
||||
<Button variant="ghost" onclick={handleClear} aria-label="Clear selection">
|
||||
<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"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
{#if error}
|
||||
<p class="error-message">{error}</p>
|
||||
{/if}
|
||||
|
||||
<!-- Media Library Modal -->
|
||||
<MediaLibraryModal
|
||||
bind:isOpen={showModal}
|
||||
{mode}
|
||||
{fileType}
|
||||
{selectedIds}
|
||||
title={modalTitle}
|
||||
confirmText={confirmText}
|
||||
onselect={handleMediaSelect}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.media-input {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit;
|
||||
}
|
||||
|
||||
.input-label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: $grey-20;
|
||||
|
||||
.required {
|
||||
color: $red-60;
|
||||
margin-left: $unit-half;
|
||||
}
|
||||
}
|
||||
|
||||
.selected-media {
|
||||
padding: $unit-2x;
|
||||
background-color: $grey-95;
|
||||
border-radius: $card-corner-radius;
|
||||
border: 1px solid $grey-85;
|
||||
}
|
||||
|
||||
.media-preview {
|
||||
&.single {
|
||||
display: flex;
|
||||
gap: $unit-2x;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
&.multiple {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit;
|
||||
}
|
||||
}
|
||||
|
||||
.media-thumbnail {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: calc($card-corner-radius - 2px);
|
||||
overflow: hidden;
|
||||
background-color: $grey-90;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
&.overflow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: $grey-80;
|
||||
color: $grey-30;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.media-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: $grey-60;
|
||||
}
|
||||
|
||||
.media-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.filename {
|
||||
margin: 0 0 $unit-half 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: $grey-10;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.file-meta {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: $grey-40;
|
||||
}
|
||||
}
|
||||
|
||||
.media-grid {
|
||||
display: flex;
|
||||
gap: $unit;
|
||||
margin-bottom: $unit;
|
||||
}
|
||||
|
||||
.selection-summary {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
color: $grey-30;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.input-field {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 1px solid $grey-80;
|
||||
border-radius: $card-corner-radius;
|
||||
background-color: white;
|
||||
transition: border-color 0.2s ease;
|
||||
|
||||
&:focus-within {
|
||||
border-color: $blue-60;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
&.has-error {
|
||||
border-color: $red-60;
|
||||
|
||||
&:focus-within {
|
||||
border-color: $red-60;
|
||||
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.media-input-field {
|
||||
flex: 1;
|
||||
padding: $unit $unit-2x;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 0.875rem;
|
||||
color: $grey-10;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&.placeholder {
|
||||
color: $grey-50;
|
||||
}
|
||||
|
||||
&[readonly] {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.input-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-right: $unit-half;
|
||||
gap: $unit-half;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: $red-60;
|
||||
}
|
||||
|
||||
// Responsive adjustments
|
||||
@media (max-width: 640px) {
|
||||
.media-preview.single {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.media-thumbnail {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
|
||||
.media-grid {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
229
src/lib/components/admin/MediaLibraryModal.svelte
Normal file
229
src/lib/components/admin/MediaLibraryModal.svelte
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
<script lang="ts">
|
||||
import Modal from './Modal.svelte'
|
||||
import Button from './Button.svelte'
|
||||
import MediaSelector from './MediaSelector.svelte'
|
||||
import type { Media } from '@prisma/client'
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean
|
||||
mode: 'single' | 'multiple'
|
||||
fileType?: 'image' | 'video' | 'all'
|
||||
selectedIds?: number[]
|
||||
title?: string
|
||||
confirmText?: string
|
||||
onSelect: (media: Media | Media[]) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
let {
|
||||
isOpen = $bindable(),
|
||||
mode,
|
||||
fileType = 'all',
|
||||
selectedIds = [],
|
||||
title = mode === 'single' ? 'Select Media' : 'Select Media Files',
|
||||
confirmText = mode === 'single' ? 'Select' : 'Select Files',
|
||||
onSelect,
|
||||
onClose
|
||||
}: Props = $props()
|
||||
|
||||
let selectedMedia = $state<Media[]>([])
|
||||
let isLoading = $state(false)
|
||||
|
||||
function handleMediaSelect(media: Media[]) {
|
||||
selectedMedia = media
|
||||
}
|
||||
|
||||
function handleConfirm() {
|
||||
if (selectedMedia.length === 0) return
|
||||
|
||||
if (mode === 'single') {
|
||||
onSelect(selectedMedia[0])
|
||||
} else {
|
||||
onSelect(selectedMedia)
|
||||
}
|
||||
|
||||
handleClose()
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
selectedMedia = []
|
||||
isOpen = false
|
||||
onClose()
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
handleClose()
|
||||
}
|
||||
|
||||
// Computed properties
|
||||
const canConfirm = $derived(selectedMedia.length > 0)
|
||||
const selectionCount = $derived(selectedMedia.length)
|
||||
const footerText = $derived(
|
||||
mode === 'single'
|
||||
? canConfirm ? '1 item selected' : 'No item selected'
|
||||
: `${selectionCount} item${selectionCount !== 1 ? 's' : ''} selected`
|
||||
)
|
||||
</script>
|
||||
|
||||
<Modal {isOpen} size="full" closeOnBackdrop={false} showCloseButton={false} on:close={handleClose}>
|
||||
<div class="media-library-modal">
|
||||
<!-- Header -->
|
||||
<header class="modal-header">
|
||||
<div class="header-content">
|
||||
<h2 class="modal-title">{title}</h2>
|
||||
<p class="modal-subtitle">
|
||||
{#if fileType === 'image'}
|
||||
Browse and select image files
|
||||
{:else if fileType === 'video'}
|
||||
Browse and select video files
|
||||
{:else}
|
||||
Browse and select media files
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="ghost" iconOnly onclick={handleClose} 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>
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<!-- Media Browser -->
|
||||
<div class="modal-body">
|
||||
<MediaSelector
|
||||
{mode}
|
||||
{fileType}
|
||||
{selectedIds}
|
||||
on:select={(e) => handleMediaSelect(e.detail)}
|
||||
bind:loading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="modal-footer">
|
||||
<div class="footer-info">
|
||||
<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}
|
||||
>
|
||||
{confirmText}
|
||||
</Button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<style lang="scss">
|
||||
.media-library-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 80vh;
|
||||
max-height: 90vh;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: $unit-3x $unit-4x;
|
||||
border-bottom: 1px solid $grey-80;
|
||||
background-color: white;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 $unit-half 0;
|
||||
color: $grey-10;
|
||||
}
|
||||
|
||||
.modal-subtitle {
|
||||
font-size: 0.875rem;
|
||||
color: $grey-30;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: $unit-3x $unit-4x;
|
||||
border-top: 1px solid $grey-80;
|
||||
background-color: $grey-95;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.footer-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.selection-count {
|
||||
font-size: 0.875rem;
|
||||
color: $grey-30;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.footer-actions {
|
||||
display: flex;
|
||||
gap: $unit-2x;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
// Responsive adjustments
|
||||
@media (max-width: 768px) {
|
||||
.modal-header {
|
||||
padding: $unit-2x $unit-3x;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: $unit-2x $unit-3x;
|
||||
flex-direction: column;
|
||||
gap: $unit-2x;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.footer-info {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.footer-actions {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
519
src/lib/components/admin/MediaSelector.svelte
Normal file
519
src/lib/components/admin/MediaSelector.svelte
Normal file
|
|
@ -0,0 +1,519 @@
|
|||
<script lang="ts">
|
||||
import { createEventDispatcher, onMount } from 'svelte'
|
||||
import Input from './Input.svelte'
|
||||
import Button from './Button.svelte'
|
||||
import LoadingSpinner from './LoadingSpinner.svelte'
|
||||
import type { Media } from '@prisma/client'
|
||||
|
||||
interface Props {
|
||||
mode: 'single' | 'multiple'
|
||||
fileType?: 'image' | 'video' | 'all'
|
||||
selectedIds?: number[]
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
mode,
|
||||
fileType = 'all',
|
||||
selectedIds = [],
|
||||
loading = $bindable(false)
|
||||
}: Props = $props()
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
select: Media[]
|
||||
}>()
|
||||
|
||||
// State
|
||||
let media = $state<Media[]>([])
|
||||
let selectedMedia = $state<Media[]>([])
|
||||
let currentPage = $state(1)
|
||||
let totalPages = $state(1)
|
||||
let total = $state(0)
|
||||
let searchQuery = $state('')
|
||||
let filterType = $state<string>(fileType === 'all' ? 'all' : fileType)
|
||||
let searchTimeout: ReturnType<typeof setTimeout>
|
||||
|
||||
// Initialize selected media from IDs
|
||||
$effect(() => {
|
||||
if (selectedIds.length > 0 && media.length > 0) {
|
||||
selectedMedia = media.filter(item => selectedIds.includes(item.id))
|
||||
dispatch('select', selectedMedia)
|
||||
}
|
||||
})
|
||||
|
||||
// Watch for search query changes with debounce
|
||||
$effect(() => {
|
||||
if (searchQuery !== undefined) {
|
||||
clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => {
|
||||
currentPage = 1
|
||||
loadMedia()
|
||||
}, 300)
|
||||
}
|
||||
})
|
||||
|
||||
// Watch for filter changes
|
||||
$effect(() => {
|
||||
if (filterType !== undefined) {
|
||||
currentPage = 1
|
||||
loadMedia()
|
||||
}
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
loadMedia()
|
||||
})
|
||||
|
||||
async function loadMedia(page = 1) {
|
||||
try {
|
||||
loading = true
|
||||
const auth = localStorage.getItem('admin_auth')
|
||||
if (!auth) return
|
||||
|
||||
let url = `/api/media?page=${page}&limit=24`
|
||||
|
||||
if (filterType !== 'all') {
|
||||
url += `&mimeType=${filterType}`
|
||||
}
|
||||
|
||||
if (searchQuery) {
|
||||
url += `&search=${encodeURIComponent(searchQuery)}`
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: { Authorization: `Basic ${auth}` }
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load media')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (page === 1) {
|
||||
media = data.media
|
||||
} else {
|
||||
media = [...media, ...data.media]
|
||||
}
|
||||
|
||||
currentPage = page
|
||||
totalPages = data.pagination.totalPages
|
||||
total = data.pagination.total
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading media:', error)
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleMediaClick(item: Media) {
|
||||
if (mode === 'single') {
|
||||
selectedMedia = [item]
|
||||
dispatch('select', selectedMedia)
|
||||
} else {
|
||||
const isSelected = selectedMedia.some(m => m.id === item.id)
|
||||
|
||||
if (isSelected) {
|
||||
selectedMedia = selectedMedia.filter(m => m.id !== item.id)
|
||||
} else {
|
||||
selectedMedia = [...selectedMedia, item]
|
||||
}
|
||||
|
||||
dispatch('select', selectedMedia)
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelectAll() {
|
||||
if (selectedMedia.length === media.length) {
|
||||
selectedMedia = []
|
||||
} else {
|
||||
selectedMedia = [...media]
|
||||
}
|
||||
dispatch('select', selectedMedia)
|
||||
}
|
||||
|
||||
function loadMore() {
|
||||
if (currentPage < totalPages && !loading) {
|
||||
loadMedia(currentPage + 1)
|
||||
}
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
function isSelected(item: Media): boolean {
|
||||
return selectedMedia.some(m => m.id === item.id)
|
||||
}
|
||||
|
||||
// Computed properties
|
||||
const hasMore = $derived(currentPage < totalPages)
|
||||
const showSelectAll = $derived(mode === 'multiple' && media.length > 0)
|
||||
const allSelected = $derived(media.length > 0 && selectedMedia.length === media.length)
|
||||
</script>
|
||||
|
||||
<div class="media-selector">
|
||||
<!-- Search and Filter Controls -->
|
||||
<div class="controls">
|
||||
<div class="search-filters">
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search media files..."
|
||||
bind:value={searchQuery}
|
||||
/>
|
||||
|
||||
<select bind:value={filterType} class="filter-select">
|
||||
<option value="all">All Files</option>
|
||||
<option value="image">Images</option>
|
||||
<option value="video">Videos</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{#if showSelectAll}
|
||||
<Button variant="ghost" onclick={handleSelectAll}>
|
||||
{allSelected ? 'Clear All' : 'Select All'}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Results Info -->
|
||||
{#if total > 0}
|
||||
<div class="results-info">
|
||||
<span class="total-count">{total} file{total !== 1 ? 's' : ''} found</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Media Grid -->
|
||||
<div class="media-grid-container">
|
||||
{#if loading && media.length === 0}
|
||||
<div class="loading-container">
|
||||
<LoadingSpinner />
|
||||
<p>Loading media...</p>
|
||||
</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>
|
||||
<h3>No media found</h3>
|
||||
<p>Try adjusting your search or upload some files</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="media-grid">
|
||||
{#each media as item (item.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="media-item"
|
||||
class:selected={isSelected(item)}
|
||||
onclick={() => handleMediaClick(item)}
|
||||
>
|
||||
<!-- Thumbnail -->
|
||||
<div class="media-thumbnail">
|
||||
{#if item.thumbnailUrl}
|
||||
<img
|
||||
src={item.thumbnailUrl}
|
||||
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>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Selection Indicator -->
|
||||
{#if mode === 'multiple'}
|
||||
<div class="selection-checkbox">
|
||||
<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>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Media Info -->
|
||||
<div class="media-info">
|
||||
<div class="media-filename" title={item.filename}>
|
||||
{item.filename}
|
||||
</div>
|
||||
<div class="media-meta">
|
||||
<span class="file-size">{formatFileSize(item.size)}</span>
|
||||
{#if item.width && item.height}
|
||||
<span class="dimensions">{item.width}×{item.height}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Load More Button -->
|
||||
{#if hasMore}
|
||||
<div class="load-more-container">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onclick={loadMore}
|
||||
disabled={loading}
|
||||
class="load-more-button"
|
||||
>
|
||||
{#if loading}
|
||||
<LoadingSpinner size="small" />
|
||||
Loading...
|
||||
{:else}
|
||||
Load More
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.media-selector {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: $unit-3x $unit-4x;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $unit-2x;
|
||||
margin-bottom: $unit-3x;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-filters {
|
||||
display: flex;
|
||||
gap: $unit-2x;
|
||||
flex: 1;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
padding: $unit $unit-2x;
|
||||
border: 1px solid $grey-80;
|
||||
border-radius: $card-corner-radius;
|
||||
background-color: white;
|
||||
font-size: 0.875rem;
|
||||
min-width: 120px;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: $blue-60;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.results-info {
|
||||
margin-bottom: $unit-2x;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.total-count {
|
||||
font-size: 0.875rem;
|
||||
color: $grey-30;
|
||||
}
|
||||
|
||||
.media-grid-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.loading-container,
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: $unit-6x;
|
||||
text-align: center;
|
||||
color: $grey-40;
|
||||
min-height: 300px;
|
||||
|
||||
svg {
|
||||
margin-bottom: $unit-2x;
|
||||
color: $grey-60;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0 0 $unit 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: $grey-20;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
.media-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: $unit-2x;
|
||||
padding-bottom: $unit-2x;
|
||||
}
|
||||
|
||||
.media-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: white;
|
||||
border: 2px solid $grey-90;
|
||||
border-radius: $card-corner-radius;
|
||||
padding: $unit;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
|
||||
&:hover {
|
||||
border-color: $grey-70;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
border-color: $blue-60;
|
||||
background-color: rgba(59, 130, 246, 0.05);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: $blue-60;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.media-thumbnail {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 120px;
|
||||
border-radius: calc($card-corner-radius - 2px);
|
||||
overflow: hidden;
|
||||
background-color: $grey-95;
|
||||
margin-bottom: $unit;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.media-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: $grey-60;
|
||||
background-color: $grey-95;
|
||||
}
|
||||
|
||||
.selection-checkbox {
|
||||
position: absolute;
|
||||
top: $unit;
|
||||
left: $unit;
|
||||
z-index: 2;
|
||||
|
||||
input {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.selected-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(59, 130, 246, 0.2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: $blue-60;
|
||||
}
|
||||
|
||||
.media-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.media-filename {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: $grey-10;
|
||||
margin-bottom: $unit-half;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.media-meta {
|
||||
display: flex;
|
||||
gap: $unit;
|
||||
font-size: 0.75rem;
|
||||
color: $grey-40;
|
||||
}
|
||||
|
||||
.load-more-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: $unit-3x 0;
|
||||
}
|
||||
|
||||
// Responsive adjustments
|
||||
@media (max-width: 768px) {
|
||||
.media-selector {
|
||||
padding: $unit-2x;
|
||||
}
|
||||
|
||||
.controls {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: $unit-2x;
|
||||
}
|
||||
|
||||
.search-filters {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.media-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: $unit;
|
||||
}
|
||||
|
||||
.media-thumbnail {
|
||||
height: 100px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
139
src/lib/components/admin/Modal.svelte
Normal file
139
src/lib/components/admin/Modal.svelte
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
<script lang="ts">
|
||||
import { createEventDispatcher, onMount } from 'svelte'
|
||||
import { fade } from 'svelte/transition'
|
||||
import Button from './Button.svelte'
|
||||
|
||||
export let isOpen = false
|
||||
export let size: 'small' | 'medium' | 'large' | 'full' = 'medium'
|
||||
export let closeOnBackdrop = true
|
||||
export let closeOnEscape = true
|
||||
export let showCloseButton = true
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
function handleClose() {
|
||||
isOpen = false
|
||||
dispatch('close')
|
||||
}
|
||||
|
||||
function handleBackdropClick() {
|
||||
if (closeOnBackdrop) {
|
||||
handleClose()
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape' && closeOnEscape) {
|
||||
handleClose()
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeydown)
|
||||
}
|
||||
})
|
||||
|
||||
$: modalClass = `modal-${size}`
|
||||
</script>
|
||||
|
||||
{#if isOpen}
|
||||
<div class="modal-backdrop" on:click={handleBackdropClick} transition:fade={{ duration: 200 }}>
|
||||
<div
|
||||
class="modal {modalClass}"
|
||||
on:click|stopPropagation
|
||||
transition:fade={{ duration: 200, delay: 50 }}
|
||||
>
|
||||
{#if showCloseButton}
|
||||
<Button
|
||||
variant="ghost"
|
||||
iconOnly
|
||||
onclick={handleClose}
|
||||
aria-label="Close modal"
|
||||
class="close-button"
|
||||
>
|
||||
<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}
|
||||
|
||||
<div class="modal-content">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
padding: $unit-2x;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background-color: white;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
position: relative;
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
||||
&.modal-small {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
&.modal-medium {
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
&.modal-large {
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
&.modal-full {
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
height: 90vh;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.close-button) {
|
||||
position: absolute;
|
||||
top: $unit-2x;
|
||||
right: $unit-2x;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,28 +1,54 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation'
|
||||
|
||||
import UniverseComposer from './UniverseComposer.svelte'
|
||||
import Button from './Button.svelte'
|
||||
|
||||
let isOpen = $state(false)
|
||||
let buttonRef: HTMLButtonElement
|
||||
|
||||
let buttonRef: HTMLElement
|
||||
let showComposer = $state(false)
|
||||
let selectedType = $state<'post' | 'essay' | 'album'>('post')
|
||||
|
||||
const postTypes = [
|
||||
{ value: 'blog', label: '📝 Blog Post', description: 'Long-form article' },
|
||||
{ value: 'microblog', label: '💭 Microblog', description: 'Short thought' },
|
||||
{ value: 'link', label: '🔗 Link', description: 'Share a link' },
|
||||
{ value: 'photo', label: '📷 Photo', description: 'Single photo post' },
|
||||
{ value: 'album', label: '🖼️ Album', description: 'Photo collection' }
|
||||
{ value: 'blog', label: 'Essay' },
|
||||
{ value: 'microblog', label: 'Post' },
|
||||
{ value: 'link', label: 'Link' },
|
||||
{ value: 'photo', label: 'Photo' },
|
||||
{ value: 'album', label: 'Album' }
|
||||
]
|
||||
|
||||
|
||||
function handleSelection(type: string) {
|
||||
isOpen = false
|
||||
goto(`/admin/posts/new?type=${type}`)
|
||||
|
||||
if (type === 'blog') {
|
||||
// Essays go straight to the full page
|
||||
goto('/admin/universe/compose?type=essay')
|
||||
} else if (type === 'microblog' || type === 'link') {
|
||||
// Posts and links open in modal
|
||||
selectedType = 'post'
|
||||
showComposer = true
|
||||
} else if (type === 'photo' || type === 'album') {
|
||||
// Photos and albums will be handled later
|
||||
selectedType = 'album'
|
||||
showComposer = true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function handleComposerClose() {
|
||||
showComposer = false
|
||||
}
|
||||
|
||||
function handleComposerSaved() {
|
||||
showComposer = false
|
||||
// Reload posts - in a real app, you'd emit an event to parent
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (!buttonRef?.contains(event.target as Node)) {
|
||||
isOpen = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
$effect(() => {
|
||||
if (isOpen) {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
|
|
@ -32,122 +58,211 @@
|
|||
</script>
|
||||
|
||||
<div class="dropdown-container">
|
||||
<button
|
||||
<Button
|
||||
bind:this={buttonRef}
|
||||
class="btn btn-primary"
|
||||
onclick={(e) => { e.stopPropagation(); isOpen = !isOpen }}
|
||||
variant="primary"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation()
|
||||
isOpen = !isOpen
|
||||
}}
|
||||
iconPosition="right"
|
||||
>
|
||||
New Post
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" class="chevron">
|
||||
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#snippet icon()}
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" class="chevron">
|
||||
<path
|
||||
d="M3 4.5L6 7.5L9 4.5"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
{/snippet}
|
||||
</Button>
|
||||
|
||||
{#if isOpen}
|
||||
<div class="dropdown-menu">
|
||||
{#each postTypes as type}
|
||||
<button
|
||||
class="dropdown-item"
|
||||
<Button
|
||||
variant="ghost"
|
||||
onclick={() => handleSelection(type.value)}
|
||||
class="dropdown-item"
|
||||
fullWidth
|
||||
pill={false}
|
||||
>
|
||||
<span class="dropdown-icon">{type.label}</span>
|
||||
<div class="dropdown-text">
|
||||
<span class="dropdown-label">{type.label.split(' ')[1]}</span>
|
||||
<span class="dropdown-description">{type.description}</span>
|
||||
{#snippet icon()}
|
||||
<div class="dropdown-icon">
|
||||
{#if type.value === 'blog'}
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<path
|
||||
d="M3 5C3 3.89543 3.89543 3 5 3H11L17 9V15C17 16.1046 16.1046 17 15 17H5C3.89543 17 3 16.1046 3 15V5Z"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
<path d="M11 3V9H17" stroke="currentColor" stroke-width="1.5" />
|
||||
<path
|
||||
d="M7 13H13"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M7 10H13"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
{:else if type.value === 'microblog'}
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<path
|
||||
d="M4 3C2.89543 3 2 3.89543 2 5V11C2 12.1046 2.89543 13 4 13H6L8 16V13H13C14.1046 13 15 12.1046 15 11V5C15 3.89543 14.1046 3 13 3H4Z"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
<path d="M5 7H12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||
<path d="M5 9H10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||
</svg>
|
||||
{:else if type.value === 'link'}
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<path
|
||||
d="M10 5H7C4.79086 5 3 6.79086 3 9C3 11.2091 4.79086 13 7 13H10M10 7H13C15.2091 7 17 8.79086 17 11C17 13.2091 15.2091 15 13 15H10"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M7 10H13"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
{:else if type.value === 'photo'}
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<rect
|
||||
x="3"
|
||||
y="3"
|
||||
width="14"
|
||||
height="14"
|
||||
rx="2"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
<circle cx="8" cy="8" r="1.5" stroke="currentColor" stroke-width="1.5" />
|
||||
<path
|
||||
d="M3 14L7 10L10 13L13 10L17 14"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
{:else if type.value === 'album'}
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<rect
|
||||
x="3"
|
||||
y="5"
|
||||
width="14"
|
||||
height="12"
|
||||
rx="2"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M5 5V3C5 1.89543 5.89543 1 7 1H13C14.1046 1 15 1.89543 15 3V5"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
<circle cx="8" cy="10" r="1.5" stroke="currentColor" stroke-width="1.5" />
|
||||
<path
|
||||
d="M3 14L7 11L10 13L13 11L17 14"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
{/snippet}
|
||||
<span class="dropdown-label">{type.label}</span>
|
||||
</Button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<UniverseComposer
|
||||
bind:isOpen={showComposer}
|
||||
initialPostType={selectedType}
|
||||
on:close={handleComposerClose}
|
||||
on:saved={handleComposerSaved}
|
||||
on:switch-to-essay
|
||||
/>
|
||||
|
||||
<style lang="scss">
|
||||
@import '$styles/variables.scss';
|
||||
|
||||
|
||||
.dropdown-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: $unit-2x $unit-3x;
|
||||
border: none;
|
||||
border-radius: 50px;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
font-size: 0.925rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $unit;
|
||||
|
||||
// Button styles are now handled by the Button component
|
||||
// Override primary button color to match original design
|
||||
:global(.dropdown-container .btn-primary) {
|
||||
background-color: $grey-10;
|
||||
|
||||
&.btn-primary {
|
||||
background-color: $grey-10;
|
||||
color: white;
|
||||
|
||||
&:hover {
|
||||
background-color: $grey-20;
|
||||
}
|
||||
&:hover:not(:disabled) {
|
||||
background-color: $grey-20;
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
background-color: $grey-30;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.chevron {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
|
||||
.dropdown-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + $unit);
|
||||
right: 0;
|
||||
background: white;
|
||||
border: 1px solid $grey-80;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
min-width: 200px;
|
||||
border: 1px solid $grey-85;
|
||||
border-radius: $unit-2x;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
min-width: 220px;
|
||||
z-index: 100;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
width: 100%;
|
||||
padding: $unit-2x;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
|
||||
// Override Button component styles for dropdown items
|
||||
:global(.dropdown-item) {
|
||||
justify-content: flex-start;
|
||||
text-align: left;
|
||||
padding: $unit-2x $unit-3x;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.dropdown-icon {
|
||||
color: $grey-40;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $unit-2x;
|
||||
text-align: left;
|
||||
transition: background 0.2s ease;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
|
||||
&:hover {
|
||||
background: $grey-95;
|
||||
}
|
||||
|
||||
&:not(:last-child) {
|
||||
border-bottom: 1px solid $grey-90;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-icon {
|
||||
font-size: 1.25rem;
|
||||
flex-shrink: 0;
|
||||
|
||||
svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
|
||||
.dropdown-label {
|
||||
font-size: 0.925rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
color: $grey-10;
|
||||
}
|
||||
|
||||
.dropdown-description {
|
||||
font-size: 0.75rem;
|
||||
color: $grey-40;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,60 +1,69 @@
|
|||
<script lang="ts">
|
||||
import FormFieldWrapper from './FormFieldWrapper.svelte'
|
||||
import Input from './Input.svelte'
|
||||
import ImageUploader from './ImageUploader.svelte'
|
||||
import type { ProjectFormData } from '$lib/types/project'
|
||||
import type { Media } from '@prisma/client'
|
||||
|
||||
interface Props {
|
||||
formData: ProjectFormData
|
||||
logoUploadInProgress: boolean
|
||||
onLogoUpload: (event: Event) => void
|
||||
onRemoveLogo: () => void
|
||||
}
|
||||
|
||||
let { formData = $bindable(), logoUploadInProgress, onLogoUpload, onRemoveLogo }: Props = $props()
|
||||
let { formData = $bindable() }: Props = $props()
|
||||
|
||||
// Convert logoUrl string to Media object for ImageUploader
|
||||
let logoMedia = $state<Media | null>(null)
|
||||
|
||||
// Update logoMedia when logoUrl changes
|
||||
$effect(() => {
|
||||
if (formData.logoUrl && !logoMedia) {
|
||||
// Create a minimal Media object from the URL for display
|
||||
logoMedia = {
|
||||
id: -1, // Temporary ID for existing URLs
|
||||
filename: 'logo.svg',
|
||||
originalName: 'logo.svg',
|
||||
mimeType: 'image/svg+xml',
|
||||
size: 0,
|
||||
url: formData.logoUrl,
|
||||
thumbnailUrl: formData.logoUrl,
|
||||
width: null,
|
||||
height: null,
|
||||
altText: null,
|
||||
description: null,
|
||||
usedIn: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
}
|
||||
} else if (!formData.logoUrl) {
|
||||
logoMedia = null
|
||||
}
|
||||
})
|
||||
|
||||
function handleLogoUpload(media: Media) {
|
||||
formData.logoUrl = media.url
|
||||
logoMedia = media
|
||||
}
|
||||
|
||||
function handleLogoRemove() {
|
||||
formData.logoUrl = ''
|
||||
logoMedia = null
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="form-section">
|
||||
<h2>Branding</h2>
|
||||
|
||||
<FormFieldWrapper
|
||||
label="Logo"
|
||||
helpText="SVG logo for project thumbnail (max 500KB)"
|
||||
>
|
||||
<div class="logo-upload-wrapper">
|
||||
{#if formData.logoUrl}
|
||||
<div class="logo-preview">
|
||||
<img src={formData.logoUrl} alt="Project logo" />
|
||||
<button
|
||||
type="button"
|
||||
class="remove-logo"
|
||||
onclick={onRemoveLogo}
|
||||
aria-label="Remove logo"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M12 4L4 12M4 4L12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<label class="logo-upload-placeholder">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/svg+xml"
|
||||
onchange={onLogoUpload}
|
||||
disabled={logoUploadInProgress}
|
||||
/>
|
||||
{#if logoUploadInProgress}
|
||||
<div class="upload-loading">Uploading...</div>
|
||||
{:else}
|
||||
<svg width="40" height="40" viewBox="0 0 40 40" fill="none">
|
||||
<rect x="8" y="8" width="24" height="24" stroke="currentColor" stroke-width="2" stroke-dasharray="4 4" rx="4"/>
|
||||
<path d="M20 16V24M16 20H24" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<span>Upload SVG Logo</span>
|
||||
{/if}
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
</FormFieldWrapper>
|
||||
<ImageUploader
|
||||
label="Project Logo"
|
||||
value={logoMedia}
|
||||
onUpload={handleLogoUpload}
|
||||
aspectRatio="1:1"
|
||||
allowAltText={true}
|
||||
maxFileSize={0.5}
|
||||
placeholder="Drag and drop an SVG logo here, or click to browse"
|
||||
helpText="Upload an SVG logo for project thumbnail (max 500KB). Square logos work best."
|
||||
showBrowseLibrary={true}
|
||||
compact={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
|
|
@ -70,117 +79,6 @@
|
|||
font-weight: 600;
|
||||
margin: 0 0 $unit-3x;
|
||||
color: $grey-10;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
.logo-upload-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $unit-2x;
|
||||
}
|
||||
|
||||
.logo-preview {
|
||||
position: relative;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
background: $grey-95;
|
||||
border: 1px solid $grey-80;
|
||||
border-radius: $unit-2x;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
|
||||
img {
|
||||
max-width: 80%;
|
||||
max-height: 80%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.remove-logo {
|
||||
position: absolute;
|
||||
top: $unit;
|
||||
right: $unit;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: white;
|
||||
border: 1px solid $grey-80;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
opacity: 0;
|
||||
|
||||
&:hover {
|
||||
background: $grey-95;
|
||||
border-color: $grey-60;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .remove-logo {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.logo-upload-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: $unit;
|
||||
width: 200px;
|
||||
height: 120px;
|
||||
background: $grey-97;
|
||||
border: 2px dashed $grey-80;
|
||||
border-radius: $unit-2x;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
input[type="file"] {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
svg {
|
||||
color: $grey-50;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 0.875rem;
|
||||
color: $grey-30;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: $grey-95;
|
||||
border-color: $grey-60;
|
||||
|
||||
svg {
|
||||
color: $grey-40;
|
||||
}
|
||||
|
||||
span {
|
||||
color: $grey-20;
|
||||
}
|
||||
}
|
||||
|
||||
&:has(input:disabled) {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.upload-loading {
|
||||
font-size: 0.875rem;
|
||||
color: $grey-40;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import { goto } from '$app/navigation'
|
||||
import { z } from 'zod'
|
||||
import AdminPage from './AdminPage.svelte'
|
||||
|
|
@ -8,7 +7,9 @@
|
|||
import Editor from './Editor.svelte'
|
||||
import ProjectMetadataForm from './ProjectMetadataForm.svelte'
|
||||
import ProjectBrandingForm from './ProjectBrandingForm.svelte'
|
||||
import ProjectGalleryForm from './ProjectGalleryForm.svelte'
|
||||
import ProjectStylingForm from './ProjectStylingForm.svelte'
|
||||
import Button from './Button.svelte'
|
||||
import { projectSchema } from '$lib/schemas/project'
|
||||
import type { Project, ProjectFormData } from '$lib/types/project'
|
||||
import { defaultProjectFormData } from '$lib/types/project'
|
||||
|
|
@ -31,7 +32,6 @@
|
|||
|
||||
// Form data
|
||||
let formData = $state<ProjectFormData>({ ...defaultProjectFormData })
|
||||
let logoUploadInProgress = $state(false)
|
||||
|
||||
// Ref to the editor component
|
||||
let editorRef: any
|
||||
|
|
@ -41,11 +41,11 @@
|
|||
{ value: 'case-study', label: 'Case Study' }
|
||||
]
|
||||
|
||||
onMount(() => {
|
||||
if (project) {
|
||||
// Watch for project changes and populate form data
|
||||
$effect(() => {
|
||||
if (project && mode === 'edit') {
|
||||
populateFormData(project)
|
||||
}
|
||||
if (mode === 'create') {
|
||||
} else if (mode === 'create') {
|
||||
isLoading = false
|
||||
}
|
||||
})
|
||||
|
|
@ -60,9 +60,11 @@
|
|||
role: data.role || '',
|
||||
technologies: Array.isArray(data.technologies) ? data.technologies.join(', ') : '',
|
||||
externalUrl: data.externalUrl || '',
|
||||
featuredImage: data.featuredImage || null,
|
||||
backgroundColor: data.backgroundColor || '',
|
||||
highlightColor: data.highlightColor || '',
|
||||
logoUrl: data.logoUrl || '',
|
||||
gallery: data.gallery || null,
|
||||
status: (data.status as 'draft' | 'published') || 'draft',
|
||||
caseStudyContent: data.caseStudyContent || {
|
||||
type: 'doc',
|
||||
|
|
@ -104,68 +106,6 @@
|
|||
formData.caseStudyContent = content
|
||||
}
|
||||
|
||||
async function handleLogoUpload(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
|
||||
if (!file) return
|
||||
|
||||
// Check if it's an SVG
|
||||
if (file.type !== 'image/svg+xml') {
|
||||
error = 'Please upload an SVG file'
|
||||
return
|
||||
}
|
||||
|
||||
// Check file size (500KB max for SVG)
|
||||
const filesize = file.size / 1024 / 1024
|
||||
if (filesize > 0.5) {
|
||||
error = `Logo file too large! File size: ${filesize.toFixed(2)} MB (max 0.5MB)`
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
logoUploadInProgress = true
|
||||
error = ''
|
||||
|
||||
const auth = localStorage.getItem('admin_auth')
|
||||
if (!auth) {
|
||||
goto('/admin/login')
|
||||
return
|
||||
}
|
||||
|
||||
const uploadFormData = new FormData()
|
||||
uploadFormData.append('file', file)
|
||||
|
||||
const response = await fetch('/api/media/upload', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Basic ${auth}`
|
||||
},
|
||||
body: uploadFormData
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Upload failed')
|
||||
}
|
||||
|
||||
const media = await response.json()
|
||||
formData.logoUrl = media.url
|
||||
successMessage = 'Logo uploaded successfully!'
|
||||
|
||||
setTimeout(() => {
|
||||
successMessage = ''
|
||||
}, 3000)
|
||||
} catch (err) {
|
||||
error = 'Failed to upload logo'
|
||||
console.error(err)
|
||||
} finally {
|
||||
logoUploadInProgress = false
|
||||
}
|
||||
}
|
||||
|
||||
function removeLogo() {
|
||||
formData.logoUrl = ''
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
// Check if we're on the case study tab and should save editor content
|
||||
|
|
@ -204,12 +144,16 @@
|
|||
.map((t) => t.trim())
|
||||
.filter(Boolean),
|
||||
externalUrl: formData.externalUrl,
|
||||
featuredImage: formData.featuredImage,
|
||||
logoUrl: formData.logoUrl,
|
||||
gallery: formData.gallery && formData.gallery.length > 0 ? formData.gallery : null,
|
||||
backgroundColor: formData.backgroundColor,
|
||||
highlightColor: formData.highlightColor,
|
||||
status: formData.status,
|
||||
caseStudyContent:
|
||||
formData.caseStudyContent && formData.caseStudyContent.content && formData.caseStudyContent.content.length > 0
|
||||
formData.caseStudyContent &&
|
||||
formData.caseStudyContent.content &&
|
||||
formData.caseStudyContent.content.length > 0
|
||||
? formData.caseStudyContent
|
||||
: null
|
||||
}
|
||||
|
|
@ -296,16 +240,20 @@
|
|||
<div class="header-actions">
|
||||
{#if !isLoading}
|
||||
<div class="save-actions">
|
||||
<button onclick={handleSave} disabled={isSaving} class="btn btn-primary save-button">
|
||||
<Button variant="primary" onclick={handleSave} disabled={isSaving} class="save-button">
|
||||
{isSaving ? 'Saving...' : formData.status === 'published' ? 'Save' : 'Save Draft'}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary chevron-button"
|
||||
class:active={showPublishMenu}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
iconOnly
|
||||
size="medium"
|
||||
active={showPublishMenu}
|
||||
onclick={togglePublishMenu}
|
||||
disabled={isSaving}
|
||||
class="chevron-button"
|
||||
>
|
||||
<svg
|
||||
slot="icon"
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 12 12"
|
||||
|
|
@ -320,13 +268,17 @@
|
|||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</Button>
|
||||
{#if showPublishMenu}
|
||||
<div class="publish-menu">
|
||||
{#if formData.status === 'published'}
|
||||
<button onclick={handleUnpublish} class="menu-item"> Unpublish </button>
|
||||
<Button variant="ghost" onclick={handleUnpublish} class="menu-item" fullWidth>
|
||||
Unpublish
|
||||
</Button>
|
||||
{:else}
|
||||
<button onclick={handlePublish} class="menu-item"> Publish </button>
|
||||
<Button variant="ghost" onclick={handlePublish} class="menu-item" fullWidth>
|
||||
Publish
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -358,12 +310,8 @@
|
|||
}}
|
||||
>
|
||||
<ProjectMetadataForm bind:formData {validationErrors} />
|
||||
<ProjectBrandingForm
|
||||
bind:formData
|
||||
bind:logoUploadInProgress
|
||||
onLogoUpload={handleLogoUpload}
|
||||
onRemoveLogo={removeLogo}
|
||||
/>
|
||||
<ProjectBrandingForm bind:formData />
|
||||
<ProjectGalleryForm bind:formData />
|
||||
<ProjectStylingForm bind:formData {validationErrors} />
|
||||
</form>
|
||||
</div>
|
||||
|
|
@ -427,49 +375,23 @@
|
|||
.save-actions {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: $unit-half;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: $unit $unit-3x;
|
||||
border-radius: 50px;
|
||||
text-decoration: none;
|
||||
font-size: 0.925rem;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
transition: all 0.2s ease;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
/* Button-specific styles handled by Button component */
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&.btn-primary {
|
||||
background-color: $grey-10;
|
||||
color: white;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: $grey-20;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.save-button {
|
||||
/* Custom button styles */
|
||||
:global(.save-button) {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
padding-right: $unit-2x;
|
||||
}
|
||||
|
||||
.chevron-button {
|
||||
:global(.chevron-button) {
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
padding: $unit $unit;
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.2);
|
||||
|
||||
&.active {
|
||||
background-color: $grey-20;
|
||||
}
|
||||
|
||||
svg {
|
||||
display: block;
|
||||
transition: transform 0.2s ease;
|
||||
|
|
@ -492,22 +414,10 @@
|
|||
min-width: 120px;
|
||||
z-index: 100;
|
||||
|
||||
.menu-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: $unit-2x $unit-3x;
|
||||
/* Menu item styles handled by Button component */
|
||||
:global(.menu-item) {
|
||||
text-align: left;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 0.925rem;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
color: $grey-10;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: $grey-95;
|
||||
}
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -537,7 +447,6 @@
|
|||
text-align: center;
|
||||
padding: $unit-6x;
|
||||
color: $grey-40;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
|
||||
.error {
|
||||
|
|
@ -549,7 +458,6 @@
|
|||
padding: $unit-3x;
|
||||
border-radius: $unit;
|
||||
margin-bottom: $unit-4x;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
max-width: 700px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
|
|
@ -571,6 +479,12 @@
|
|||
}
|
||||
}
|
||||
|
||||
.form-content form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-6x;
|
||||
}
|
||||
|
||||
.case-study-wrapper {
|
||||
background: white;
|
||||
padding: 0;
|
||||
|
|
@ -598,4 +512,4 @@
|
|||
overflow: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
|
|
|||
92
src/lib/components/admin/ProjectGalleryForm.svelte
Normal file
92
src/lib/components/admin/ProjectGalleryForm.svelte
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
<script lang="ts">
|
||||
import GalleryUploader from './GalleryUploader.svelte'
|
||||
import type { ProjectFormData } from '$lib/types/project'
|
||||
import type { Media } from '@prisma/client'
|
||||
|
||||
interface Props {
|
||||
formData: ProjectFormData
|
||||
}
|
||||
|
||||
let { formData = $bindable() }: Props = $props()
|
||||
|
||||
// Convert gallery array to Media objects for GalleryUploader
|
||||
let galleryMedia = $state<Media[]>([])
|
||||
|
||||
// Update galleryMedia when gallery changes
|
||||
$effect(() => {
|
||||
if (formData.gallery && Array.isArray(formData.gallery)) {
|
||||
// Convert gallery URLs/objects to Media objects
|
||||
galleryMedia = formData.gallery.map((item, index) => {
|
||||
if (typeof item === 'string') {
|
||||
// Handle legacy URL strings
|
||||
return {
|
||||
id: -(index + 100), // Temporary negative IDs for URLs
|
||||
filename: `gallery-${index}.jpg`,
|
||||
originalName: `gallery-${index}.jpg`,
|
||||
mimeType: 'image/jpeg',
|
||||
size: 0,
|
||||
url: item,
|
||||
thumbnailUrl: item,
|
||||
width: null,
|
||||
height: null,
|
||||
altText: null,
|
||||
description: null,
|
||||
usedIn: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
}
|
||||
} else {
|
||||
// Already a Media object
|
||||
return item
|
||||
}
|
||||
})
|
||||
} else {
|
||||
galleryMedia = []
|
||||
}
|
||||
})
|
||||
|
||||
function handleGalleryUpload(media: Media[]) {
|
||||
// Store as Media objects in the gallery field
|
||||
formData.gallery = media
|
||||
galleryMedia = media
|
||||
}
|
||||
|
||||
function handleGalleryReorder(media: Media[]) {
|
||||
formData.gallery = media
|
||||
galleryMedia = media
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="form-section">
|
||||
<h2>Project Gallery</h2>
|
||||
|
||||
<GalleryUploader
|
||||
label="Gallery Images"
|
||||
value={galleryMedia}
|
||||
onUpload={handleGalleryUpload}
|
||||
onReorder={handleGalleryReorder}
|
||||
maxItems={12}
|
||||
allowAltText={true}
|
||||
maxFileSize={10}
|
||||
placeholder="Drag and drop images here to create a project gallery"
|
||||
helpText="Upload project screenshots, mockups, or other visual assets. You can reorder images by dragging them."
|
||||
showBrowseLibrary={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.form-section {
|
||||
margin-bottom: $unit-6x;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 $unit-3x;
|
||||
color: $grey-10;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -169,7 +169,6 @@
|
|||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
|
||||
.project-metadata {
|
||||
|
|
@ -178,7 +177,6 @@
|
|||
gap: $unit;
|
||||
font-size: 0.875rem;
|
||||
color: $grey-40;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
|
||||
.status {
|
||||
&.published {
|
||||
|
|
@ -239,7 +237,6 @@
|
|||
color: $grey-20;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
|
||||
&:hover {
|
||||
background-color: $grey-95;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<script lang="ts">
|
||||
import FormFieldWrapper from './FormFieldWrapper.svelte'
|
||||
import Input from './Input.svelte'
|
||||
import ImageUploader from './ImageUploader.svelte'
|
||||
import type { ProjectFormData } from '$lib/types/project'
|
||||
|
||||
interface Props {
|
||||
|
|
@ -8,45 +9,71 @@
|
|||
}
|
||||
|
||||
let { formData = $bindable(), validationErrors }: Props = $props()
|
||||
|
||||
function handleFeaturedImageUpload(media: Media) {
|
||||
formData.featuredImage = media.url
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="form-section">
|
||||
<FormFieldWrapper label="Title" required error={validationErrors.title}>
|
||||
<input type="text" bind:value={formData.title} required placeholder="Project title" />
|
||||
</FormFieldWrapper>
|
||||
<Input
|
||||
label="Title"
|
||||
required
|
||||
error={validationErrors.title}
|
||||
bind:value={formData.title}
|
||||
placeholder="Project title"
|
||||
/>
|
||||
|
||||
<FormFieldWrapper label="Description" error={validationErrors.description}>
|
||||
<textarea
|
||||
bind:value={formData.description}
|
||||
rows="3"
|
||||
placeholder="Short description for project cards"
|
||||
/>
|
||||
</FormFieldWrapper>
|
||||
<Input
|
||||
type="textarea"
|
||||
label="Description"
|
||||
error={validationErrors.description}
|
||||
bind:value={formData.description}
|
||||
rows={3}
|
||||
placeholder="Short description for project cards"
|
||||
/>
|
||||
|
||||
<div class="form-row">
|
||||
<FormFieldWrapper label="Year" required error={validationErrors.year}>
|
||||
<input
|
||||
type="number"
|
||||
bind:value={formData.year}
|
||||
required
|
||||
min="1990"
|
||||
max={new Date().getFullYear() + 1}
|
||||
/>
|
||||
</FormFieldWrapper>
|
||||
<Input
|
||||
type="number"
|
||||
label="Year"
|
||||
required
|
||||
error={validationErrors.year}
|
||||
bind:value={formData.year}
|
||||
min={1990}
|
||||
max={new Date().getFullYear() + 1}
|
||||
/>
|
||||
|
||||
<FormFieldWrapper label="Client" error={validationErrors.client}>
|
||||
<input type="text" bind:value={formData.client} placeholder="Client or company name" />
|
||||
</FormFieldWrapper>
|
||||
<Input
|
||||
label="Client"
|
||||
error={validationErrors.client}
|
||||
bind:value={formData.client}
|
||||
placeholder="Client or company name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormFieldWrapper label="External URL" error={validationErrors.externalUrl}>
|
||||
<input type="url" bind:value={formData.externalUrl} placeholder="https://example.com" />
|
||||
</FormFieldWrapper>
|
||||
<Input
|
||||
type="url"
|
||||
label="External URL"
|
||||
error={validationErrors.externalUrl}
|
||||
bind:value={formData.externalUrl}
|
||||
placeholder="https://example.com"
|
||||
/>
|
||||
|
||||
<ImageUploader
|
||||
label="Featured Image"
|
||||
value={null}
|
||||
onUpload={handleFeaturedImageUpload}
|
||||
placeholder="Upload a featured image for this project"
|
||||
showBrowseLibrary={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.form-section {
|
||||
margin-bottom: $unit-6x;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-2x;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
|
|
@ -62,39 +89,5 @@
|
|||
@include breakpoint('phone') {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
:global(.form-field) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
input[type='text'],
|
||||
input[type='url'],
|
||||
input[type='number'],
|
||||
textarea {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: calc($unit * 1.5);
|
||||
border: 1px solid $grey-80;
|
||||
border-radius: $unit;
|
||||
font-size: 1rem;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
transition: border-color 0.2s ease;
|
||||
background-color: white;
|
||||
color: #333;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: $grey-40;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script lang="ts">
|
||||
import FormFieldWrapper from './FormFieldWrapper.svelte'
|
||||
import Input from './Input.svelte'
|
||||
import type { ProjectFormData } from '$lib/types/project'
|
||||
|
||||
interface Props {
|
||||
|
|
@ -14,44 +14,27 @@
|
|||
<h2>Styling</h2>
|
||||
|
||||
<div class="form-row">
|
||||
<FormFieldWrapper
|
||||
<Input
|
||||
type="text"
|
||||
bind:value={formData.backgroundColor}
|
||||
label="Background Color"
|
||||
helpText="Hex color for project card"
|
||||
error={validationErrors.backgroundColor}
|
||||
>
|
||||
<div class="color-input-wrapper">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={formData.backgroundColor}
|
||||
placeholder="#FFFFFF"
|
||||
pattern="^#[0-9A-Fa-f]{6}$"
|
||||
/>
|
||||
{#if formData.backgroundColor}
|
||||
<div
|
||||
class="color-preview"
|
||||
style="background-color: {formData.backgroundColor}"
|
||||
></div>
|
||||
{/if}
|
||||
</div>
|
||||
</FormFieldWrapper>
|
||||
placeholder="#FFFFFF"
|
||||
pattern="^#[0-9A-Fa-f]{6}$"
|
||||
colorSwatch={true}
|
||||
/>
|
||||
|
||||
<FormFieldWrapper
|
||||
<Input
|
||||
type="text"
|
||||
bind:value={formData.highlightColor}
|
||||
label="Highlight Color"
|
||||
helpText="Accent color for the project"
|
||||
error={validationErrors.highlightColor}
|
||||
>
|
||||
<div class="color-input-wrapper">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={formData.highlightColor}
|
||||
placeholder="#000000"
|
||||
pattern="^#[0-9A-Fa-f]{6}$"
|
||||
/>
|
||||
{#if formData.highlightColor}
|
||||
<div class="color-preview" style="background-color: {formData.highlightColor}"></div>
|
||||
{/if}
|
||||
</div>
|
||||
</FormFieldWrapper>
|
||||
placeholder="#000000"
|
||||
pattern="^#[0-9A-Fa-f]{6}$"
|
||||
colorSwatch={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -68,8 +51,7 @@
|
|||
font-weight: 600;
|
||||
margin: 0 0 $unit-3x;
|
||||
color: $grey-10;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.form-row {
|
||||
|
|
@ -81,45 +63,8 @@
|
|||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
:global(.form-field) {
|
||||
:global(.input-wrapper) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.color-input-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $unit-2x;
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: calc($unit * 1.5);
|
||||
border: 1px solid $grey-80;
|
||||
border-radius: $unit;
|
||||
font-size: 1rem;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
transition: border-color 0.2s ease;
|
||||
background-color: white;
|
||||
color: #333;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: $grey-40;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
.color-preview {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: $unit;
|
||||
border: 1px solid $grey-80;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@
|
|||
display: flex;
|
||||
align-items: center;
|
||||
gap: $unit;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
|
||||
.color-dot {
|
||||
|
|
|
|||
357
src/lib/components/admin/SimplePostForm.svelte
Normal file
357
src/lib/components/admin/SimplePostForm.svelte
Normal file
|
|
@ -0,0 +1,357 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation'
|
||||
import AdminPage from './AdminPage.svelte'
|
||||
import type { JSONContent } from '@tiptap/core'
|
||||
import Editor from './Editor.svelte'
|
||||
import Button from './Button.svelte'
|
||||
import Input from './Input.svelte'
|
||||
|
||||
interface Props {
|
||||
postType: 'microblog' | 'link'
|
||||
postId?: number
|
||||
initialData?: {
|
||||
title?: string
|
||||
content?: JSONContent
|
||||
linkUrl?: string
|
||||
linkDescription?: string
|
||||
status: 'draft' | 'published'
|
||||
}
|
||||
mode: 'create' | 'edit'
|
||||
}
|
||||
|
||||
let { postType, postId, initialData, mode }: Props = $props()
|
||||
|
||||
// State
|
||||
let isSaving = $state(false)
|
||||
let error = $state('')
|
||||
let status = $state<'draft' | 'published'>(initialData?.status || 'draft')
|
||||
|
||||
// Form data
|
||||
let content = $state<JSONContent>(initialData?.content || { type: 'doc', content: [] })
|
||||
let linkUrl = $state(initialData?.linkUrl || '')
|
||||
let linkDescription = $state(initialData?.linkDescription || '')
|
||||
let title = $state(initialData?.title || '')
|
||||
|
||||
// Character count for posts
|
||||
const maxLength = 280
|
||||
const textContent = $derived(() => {
|
||||
if (!content.content) return ''
|
||||
return content.content
|
||||
.map((node: any) => node.content?.map((n: any) => n.text || '').join('') || '')
|
||||
.join('\n')
|
||||
})
|
||||
const charCount = $derived(textContent().length)
|
||||
const isOverLimit = $derived(charCount > maxLength)
|
||||
|
||||
// Check if form has content
|
||||
const hasContent = $derived(() => {
|
||||
if (postType === 'microblog') {
|
||||
return textContent().trim().length > 0
|
||||
} else if (postType === 'link') {
|
||||
return linkUrl && linkUrl.trim().length > 0
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
async function handleSave(publishStatus: 'draft' | 'published') {
|
||||
if (postType === 'microblog' && isOverLimit) {
|
||||
error = 'Post is too long'
|
||||
return
|
||||
}
|
||||
|
||||
if (postType === 'link' && !linkUrl) {
|
||||
error = 'Link URL is required'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
isSaving = true
|
||||
error = ''
|
||||
|
||||
const auth = localStorage.getItem('admin_auth')
|
||||
if (!auth) {
|
||||
goto('/admin/login')
|
||||
return
|
||||
}
|
||||
|
||||
const payload: any = {
|
||||
postType,
|
||||
status: publishStatus
|
||||
}
|
||||
|
||||
if (postType === 'microblog') {
|
||||
payload.content = content
|
||||
} else if (postType === 'link') {
|
||||
payload.title = title || linkUrl
|
||||
payload.linkUrl = linkUrl
|
||||
payload.linkDescription = linkDescription
|
||||
}
|
||||
|
||||
const url = mode === 'edit' ? `/api/posts/${postId}` : '/api/posts'
|
||||
const method = mode === 'edit' ? 'PUT' : 'POST'
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `Basic ${auth}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to ${mode === 'edit' ? 'save' : 'create'} post`)
|
||||
}
|
||||
|
||||
const savedPost = await response.json()
|
||||
|
||||
// Redirect back to posts list after creation
|
||||
goto('/admin/posts')
|
||||
} catch (err) {
|
||||
error = `Failed to ${mode === 'edit' ? 'save' : 'create'} post`
|
||||
console.error(err)
|
||||
} finally {
|
||||
isSaving = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<AdminPage>
|
||||
<header slot="header">
|
||||
<div class="header-left">
|
||||
<Button variant="ghost" iconOnly onclick={() => goto('/admin/posts')}>
|
||||
<svg slot="icon" 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"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
<h1>
|
||||
{#if postType === 'microblog'}
|
||||
New Post
|
||||
{:else}
|
||||
Share Link
|
||||
{/if}
|
||||
</h1>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<Button variant="secondary" onclick={() => handleSave('draft')} disabled={isSaving}>
|
||||
Save Draft
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onclick={() => handleSave('published')}
|
||||
disabled={isSaving || !hasContent() || (postType === 'microblog' && isOverLimit)}
|
||||
>
|
||||
{isSaving ? 'Posting...' : 'Post'}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="composer-container">
|
||||
{#if error}
|
||||
<div class="error-message">{error}</div>
|
||||
{/if}
|
||||
|
||||
<div class="composer">
|
||||
{#if postType === 'microblog'}
|
||||
<div class="post-composer">
|
||||
<Editor
|
||||
bind:data={content}
|
||||
placeholder="What's on your mind?"
|
||||
minHeight={120}
|
||||
autofocus={true}
|
||||
class="simple-editor"
|
||||
simpleMode={true}
|
||||
/>
|
||||
<div class="composer-footer">
|
||||
<span class="char-count" class:over-limit={isOverLimit}>
|
||||
{charCount} / {maxLength}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{:else if postType === 'link'}
|
||||
<div class="link-composer">
|
||||
<Input
|
||||
type="url"
|
||||
bind:value={linkUrl}
|
||||
placeholder="https://example.com"
|
||||
inputClass="link-input"
|
||||
required
|
||||
autofocus
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={title}
|
||||
placeholder="Link title (optional)"
|
||||
class="title-input"
|
||||
/>
|
||||
<Input
|
||||
type="textarea"
|
||||
bind:value={linkDescription}
|
||||
placeholder="Why is this interesting?"
|
||||
inputClass="description-input"
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</AdminPage>
|
||||
|
||||
<style lang="scss">
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
gap: $unit-2x;
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $unit-2x;
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: $grey-10;
|
||||
}
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: $unit-2x;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.composer-container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: $unit-3x;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
padding: $unit-2x;
|
||||
border-radius: $unit;
|
||||
margin-bottom: $unit-3x;
|
||||
background-color: #fee;
|
||||
color: #d33;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.composer {
|
||||
background: white;
|
||||
border-radius: $unit-2x;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.post-composer {
|
||||
padding: $unit-3x;
|
||||
|
||||
:global(.simple-editor) {
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.5;
|
||||
|
||||
:global(.tiptap) {
|
||||
min-height: 120px;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.composer-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: $unit-2x;
|
||||
padding-top: $unit-2x;
|
||||
border-top: 1px solid $grey-80;
|
||||
}
|
||||
|
||||
.char-count {
|
||||
font-size: 0.875rem;
|
||||
color: $grey-50;
|
||||
|
||||
&.over-limit {
|
||||
color: $red-60;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.link-composer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
:global(.input-wrapper) {
|
||||
border-radius: 0;
|
||||
|
||||
&:first-child {
|
||||
border-bottom: 1px solid $grey-90;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-top: 1px solid $grey-90;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.link-input) {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 500;
|
||||
color: $primary-color;
|
||||
padding: $unit-3x;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
|
||||
&:focus {
|
||||
border: none;
|
||||
background: $grey-97;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.description-input) {
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
color: $grey-20;
|
||||
padding: $unit-3x;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
min-height: 100px;
|
||||
|
||||
&:focus {
|
||||
border: none;
|
||||
background: $grey-97;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.title-input {
|
||||
width: 100%;
|
||||
padding: $unit-3x;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 1rem;
|
||||
color: $grey-10;
|
||||
border-bottom: 1px solid $grey-90;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
background: $grey-97;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: $grey-60;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
759
src/lib/components/admin/UniverseComposer.svelte
Normal file
759
src/lib/components/admin/UniverseComposer.svelte
Normal file
|
|
@ -0,0 +1,759 @@
|
|||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import { goto } from '$app/navigation'
|
||||
import Modal from './Modal.svelte'
|
||||
import Editor from './Editor.svelte'
|
||||
import AdminSegmentedControl from './AdminSegmentedControl.svelte'
|
||||
import FormFieldWrapper from './FormFieldWrapper.svelte'
|
||||
import Button from './Button.svelte'
|
||||
import Input from './Input.svelte'
|
||||
import type { JSONContent } from '@tiptap/core'
|
||||
|
||||
export let isOpen = false
|
||||
export let initialMode: 'modal' | 'page' = 'modal'
|
||||
export let initialPostType: 'post' | 'essay' | 'album' = 'post'
|
||||
export let initialContent: JSONContent | undefined = undefined
|
||||
|
||||
type PostType = 'post' | 'essay' | 'album'
|
||||
type ComposerMode = 'modal' | 'page'
|
||||
|
||||
let postType: PostType = initialPostType
|
||||
let mode: ComposerMode = initialMode
|
||||
let content: JSONContent = initialContent || {
|
||||
type: 'doc',
|
||||
content: [{ type: 'paragraph' }]
|
||||
}
|
||||
let linkUrl = ''
|
||||
let linkTitle = ''
|
||||
let linkDescription = ''
|
||||
let showLinkFields = false
|
||||
let characterCount = 0
|
||||
let editorInstance: Editor
|
||||
|
||||
// Essay metadata
|
||||
let essayTitle = ''
|
||||
let essaySlug = ''
|
||||
let essayExcerpt = ''
|
||||
let essayTags = ''
|
||||
let essayTab = 0
|
||||
|
||||
const CHARACTER_LIMIT = 280
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
function handleClose() {
|
||||
if (hasContent() && !confirm('Are you sure you want to close? Your changes will be lost.')) {
|
||||
return
|
||||
}
|
||||
resetComposer()
|
||||
isOpen = false
|
||||
dispatch('close')
|
||||
}
|
||||
|
||||
function hasContent(): boolean {
|
||||
return characterCount > 0 || linkUrl.length > 0
|
||||
}
|
||||
|
||||
function resetComposer() {
|
||||
postType = 'post'
|
||||
mode = 'modal'
|
||||
content = {
|
||||
type: 'doc',
|
||||
content: [{ type: 'paragraph' }]
|
||||
}
|
||||
linkUrl = ''
|
||||
linkTitle = ''
|
||||
linkDescription = ''
|
||||
showLinkFields = false
|
||||
characterCount = 0
|
||||
if (editorInstance) {
|
||||
editorInstance.clear()
|
||||
}
|
||||
}
|
||||
|
||||
function switchToEssay() {
|
||||
const contentParam = content ? encodeURIComponent(JSON.stringify(content)) : ''
|
||||
goto(`/admin/universe/compose?type=essay${contentParam ? `&content=${contentParam}` : ''}`)
|
||||
}
|
||||
|
||||
function generateSlug(title: string): string {
|
||||
return title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
}
|
||||
|
||||
$: if (essayTitle && !essaySlug) {
|
||||
essaySlug = generateSlug(essayTitle)
|
||||
}
|
||||
|
||||
function toggleLinkFields() {
|
||||
showLinkFields = !showLinkFields
|
||||
}
|
||||
|
||||
function getTextFromContent(json: JSONContent): number {
|
||||
if (!json || !json.content) return 0
|
||||
|
||||
let text = ''
|
||||
|
||||
function extractText(node: any) {
|
||||
if (node.text) {
|
||||
text += node.text
|
||||
}
|
||||
if (node.content && Array.isArray(node.content)) {
|
||||
node.content.forEach(extractText)
|
||||
}
|
||||
}
|
||||
|
||||
extractText(json)
|
||||
return text.length
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!hasContent() && postType !== 'essay') return
|
||||
if (postType === 'essay' && !essayTitle) return
|
||||
|
||||
let postData: any = {
|
||||
content,
|
||||
status: 'published'
|
||||
}
|
||||
|
||||
if (postType === 'essay') {
|
||||
postData = {
|
||||
...postData,
|
||||
type: 'blog',
|
||||
title: essayTitle,
|
||||
slug: essaySlug,
|
||||
excerpt: essayExcerpt,
|
||||
tags: essayTags ? essayTags.split(',').map((tag) => tag.trim()) : []
|
||||
}
|
||||
} else if (showLinkFields) {
|
||||
postData = {
|
||||
...postData,
|
||||
type: 'link',
|
||||
linkUrl,
|
||||
linkTitle,
|
||||
linkDescription
|
||||
}
|
||||
} else {
|
||||
postData = {
|
||||
...postData,
|
||||
type: 'microblog'
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/posts', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(postData)
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
resetComposer()
|
||||
isOpen = false
|
||||
dispatch('saved')
|
||||
if (postType === 'essay') {
|
||||
goto('/admin/posts')
|
||||
}
|
||||
} else {
|
||||
console.error('Failed to save post')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving post:', error)
|
||||
}
|
||||
}
|
||||
|
||||
$: isOverLimit = characterCount > CHARACTER_LIMIT
|
||||
$: canSave =
|
||||
(postType === 'post' && characterCount > 0 && !isOverLimit) ||
|
||||
(showLinkFields && linkUrl.length > 0) ||
|
||||
(postType === 'essay' && essayTitle.length > 0 && content)
|
||||
</script>
|
||||
|
||||
{#if mode === 'modal'}
|
||||
<Modal bind:isOpen size="medium" on:close={handleClose} showCloseButton={false}>
|
||||
<div class="composer">
|
||||
<div class="composer-header">
|
||||
<Button variant="ghost" onclick={handleClose}>Cancel</Button>
|
||||
<div class="header-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
iconOnly
|
||||
onclick={switchToEssay}
|
||||
title="Expand to essay"
|
||||
class="expand-button"
|
||||
>
|
||||
<svg slot="icon" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path
|
||||
d="M10 6L14 2M14 2H10M14 2V6"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M6 10L2 14M2 14H6M2 14V10"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
<Button variant="primary" onclick={handleSave} disabled={!canSave}>Post</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="composer-body">
|
||||
<Editor
|
||||
bind:this={editorInstance}
|
||||
bind:data={content}
|
||||
onChange={(newContent) => {
|
||||
content = newContent
|
||||
characterCount = getTextFromContent(newContent)
|
||||
}}
|
||||
placeholder="What's on your mind?"
|
||||
simpleMode={true}
|
||||
autofocus={true}
|
||||
minHeight={80}
|
||||
showToolbar={false}
|
||||
class="composer-editor"
|
||||
/>
|
||||
|
||||
{#if showLinkFields}
|
||||
<div class="link-fields">
|
||||
<Input
|
||||
type="url"
|
||||
bind:value={linkUrl}
|
||||
placeholder="https://example.com"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<Input bind:value={linkTitle} placeholder="Link title (optional)" autocomplete="off" />
|
||||
<Input
|
||||
type="textarea"
|
||||
bind:value={linkDescription}
|
||||
placeholder="Add context..."
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="composer-footer">
|
||||
<div class="footer-left">
|
||||
<Button
|
||||
variant="ghost"
|
||||
iconOnly
|
||||
size="icon"
|
||||
onclick={toggleLinkFields}
|
||||
active={showLinkFields}
|
||||
title="Add link"
|
||||
class="tool-button"
|
||||
>
|
||||
<svg slot="icon" width="18" height="18" viewBox="0 0 18 18" fill="none">
|
||||
<path
|
||||
d="M8 10a3 3 0 0 1 0-5l2.5-2.5a3 3 0 0 1 4.243 4.243l-1.25 1.25"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M10 8a3 3 0 0 1 0 5l-2.5 2.5a3 3 0 0 1-4.243-4.243l1.25-1.25"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M11 7l-4 4"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
iconOnly
|
||||
size="icon"
|
||||
onclick={() => alert('Image upload coming soon!')}
|
||||
title="Add image"
|
||||
class="tool-button"
|
||||
>
|
||||
<svg slot="icon" width="18" height="18" viewBox="0 0 18 18" fill="none">
|
||||
<rect
|
||||
x="2"
|
||||
y="2"
|
||||
width="14"
|
||||
height="14"
|
||||
rx="2"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
<circle cx="5.5" cy="5.5" r="1.5" fill="currentColor" />
|
||||
<path
|
||||
d="M2 12l4-4 3 3 5-5 2 2"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="footer-right">
|
||||
{#if postType === 'post' && !showLinkFields}
|
||||
<span
|
||||
class="character-count"
|
||||
class:warning={characterCount > CHARACTER_LIMIT * 0.9}
|
||||
class:error={isOverLimit}
|
||||
>
|
||||
{CHARACTER_LIMIT - characterCount}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
{:else if mode === 'page'}
|
||||
{#if postType === 'essay'}
|
||||
<div class="essay-composer">
|
||||
<div class="essay-header">
|
||||
<h1>New Essay</h1>
|
||||
<div class="essay-actions">
|
||||
<Button variant="secondary" onclick={() => goto('/admin/posts')}>Cancel</Button>
|
||||
<Button variant="primary" onclick={handleSave} disabled={!canSave}>Publish</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AdminSegmentedControl bind:selectedIndex={essayTab}>
|
||||
<button slot="0">Metadata</button>
|
||||
<button slot="1">Content</button>
|
||||
</AdminSegmentedControl>
|
||||
|
||||
<div class="essay-content">
|
||||
{#if essayTab === 0}
|
||||
<div class="metadata-section">
|
||||
<Input label="Title" bind:value={essayTitle} placeholder="Essay title" required />
|
||||
|
||||
<Input label="Slug" bind:value={essaySlug} placeholder="essay-slug" />
|
||||
|
||||
<Input
|
||||
type="textarea"
|
||||
label="Excerpt"
|
||||
bind:value={essayExcerpt}
|
||||
placeholder="Brief description of your essay"
|
||||
rows={3}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Tags"
|
||||
bind:value={essayTags}
|
||||
placeholder="design, development, thoughts"
|
||||
helpText="Comma-separated list of tags"
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="content-section">
|
||||
<Editor
|
||||
bind:this={editorInstance}
|
||||
bind:data={content}
|
||||
onChange={(newContent) => {
|
||||
content = newContent
|
||||
characterCount = getTextFromContent(newContent)
|
||||
}}
|
||||
placeholder="Start writing your essay..."
|
||||
simpleMode={false}
|
||||
autofocus={true}
|
||||
minHeight={500}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="inline-composer">
|
||||
<Button
|
||||
variant="ghost"
|
||||
iconOnly
|
||||
size="icon"
|
||||
onclick={switchToEssay}
|
||||
title="Switch to essay mode"
|
||||
class="floating-expand-button"
|
||||
>
|
||||
<svg slot="icon" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path
|
||||
d="M10 6L14 2M14 2H10M14 2V6"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M6 10L2 14M2 14H6M2 14V10"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
<div class="composer-body">
|
||||
<Editor
|
||||
bind:this={editorInstance}
|
||||
bind:data={content}
|
||||
onChange={(newContent) => {
|
||||
content = newContent
|
||||
characterCount = getTextFromContent(newContent)
|
||||
}}
|
||||
placeholder="What's on your mind?"
|
||||
simpleMode={true}
|
||||
autofocus={true}
|
||||
minHeight={120}
|
||||
showToolbar={false}
|
||||
class="inline-composer-editor"
|
||||
/>
|
||||
|
||||
{#if showLinkFields}
|
||||
<div class="link-fields">
|
||||
<Input
|
||||
type="url"
|
||||
bind:value={linkUrl}
|
||||
placeholder="https://example.com"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<Input bind:value={linkTitle} placeholder="Link title (optional)" autocomplete="off" />
|
||||
<Input
|
||||
type="textarea"
|
||||
bind:value={linkDescription}
|
||||
placeholder="Add context..."
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="composer-footer">
|
||||
<div class="footer-left">
|
||||
<Button
|
||||
variant="ghost"
|
||||
iconOnly
|
||||
size="icon"
|
||||
onclick={toggleLinkFields}
|
||||
active={showLinkFields}
|
||||
title="Add link"
|
||||
class="tool-button"
|
||||
>
|
||||
<svg slot="icon" width="18" height="18" viewBox="0 0 18 18" fill="none">
|
||||
<path
|
||||
d="M8 10a3 3 0 0 1 0-5l2.5-2.5a3 3 0 0 1 4.243 4.243l-1.25 1.25"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M10 8a3 3 0 0 1 0 5l-2.5 2.5a3 3 0 0 1-4.243-4.243l1.25-1.25"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M11 7l-4 4"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
iconOnly
|
||||
size="icon"
|
||||
onclick={() => alert('Image upload coming soon!')}
|
||||
title="Add image"
|
||||
class="tool-button"
|
||||
>
|
||||
<svg slot="icon" width="18" height="18" viewBox="0 0 18 18" fill="none">
|
||||
<rect
|
||||
x="2"
|
||||
y="2"
|
||||
width="14"
|
||||
height="14"
|
||||
rx="2"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
<circle cx="5.5" cy="5.5" r="1.5" fill="currentColor" />
|
||||
<path
|
||||
d="M2 12l4-4 3 3 5-5 2 2"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="footer-right">
|
||||
{#if !showLinkFields}
|
||||
<span
|
||||
class="character-count"
|
||||
class:warning={characterCount > CHARACTER_LIMIT * 0.9}
|
||||
class:error={isOverLimit}
|
||||
>
|
||||
{CHARACTER_LIMIT - characterCount}
|
||||
</span>
|
||||
{/if}
|
||||
<Button variant="primary" onclick={handleSave} disabled={!canSave}>Post</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
@import '$styles/variables.scss';
|
||||
|
||||
.composer {
|
||||
padding: 0;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.composer-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: $unit-2x;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $unit;
|
||||
}
|
||||
|
||||
.composer-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
:global(.edra-editor) {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.composer-editor) {
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
|
||||
:global(.editor-container) {
|
||||
padding: 0 $unit-3x;
|
||||
}
|
||||
|
||||
:global(.editor-content) {
|
||||
padding: 0;
|
||||
min-height: 80px;
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
:global(.ProseMirror) {
|
||||
padding: 0;
|
||||
min-height: 80px;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&.ProseMirror-focused .is-editor-empty:first-child::before {
|
||||
color: $grey-40;
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
pointer-events: none;
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.link-fields {
|
||||
padding: 0 $unit-2x $unit-2x;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit;
|
||||
}
|
||||
|
||||
.composer-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: calc($unit * 1.5) $unit-2x;
|
||||
border-top: 1px solid $grey-80;
|
||||
background-color: $grey-5;
|
||||
}
|
||||
|
||||
.footer-left,
|
||||
.footer-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $unit-half;
|
||||
}
|
||||
|
||||
.character-count {
|
||||
font-size: 13px;
|
||||
color: $grey-50;
|
||||
font-weight: 400;
|
||||
padding: 0 $unit;
|
||||
min-width: 30px;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
|
||||
&.warning {
|
||||
color: $universe-color;
|
||||
}
|
||||
|
||||
&.error {
|
||||
color: $red-50;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
// Essay composer styles
|
||||
.essay-composer {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: $unit-3x;
|
||||
}
|
||||
|
||||
.essay-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: $unit-3x;
|
||||
|
||||
h1 {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.essay-actions {
|
||||
display: flex;
|
||||
gap: $unit;
|
||||
}
|
||||
|
||||
.essay-content {
|
||||
margin-top: $unit-3x;
|
||||
}
|
||||
|
||||
.metadata-section {
|
||||
max-width: 600px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-3x;
|
||||
}
|
||||
|
||||
.content-section {
|
||||
:global(.editor) {
|
||||
min-height: 500px;
|
||||
}
|
||||
}
|
||||
|
||||
// Inline composer styles
|
||||
.inline-composer {
|
||||
position: relative;
|
||||
background: white;
|
||||
border-radius: $unit-2x;
|
||||
border: 1px solid $grey-80;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
|
||||
.composer-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
:global(.edra-editor) {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:global(.floating-expand-button) {
|
||||
position: absolute !important;
|
||||
top: $unit-2x;
|
||||
right: $unit-2x;
|
||||
z-index: 10;
|
||||
background-color: rgba(255, 255, 255, 0.9) !important;
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid $grey-80 !important;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.95) !important;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.inline-composer-editor) {
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
background: transparent !important;
|
||||
|
||||
:global(.editor-container) {
|
||||
padding: $unit * 1.5 $unit-3x 0;
|
||||
}
|
||||
|
||||
:global(.editor-content) {
|
||||
padding: 0;
|
||||
min-height: 120px;
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
:global(.ProseMirror) {
|
||||
padding: 0;
|
||||
min-height: 120px;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&.ProseMirror-focused .is-editor-empty:first-child::before {
|
||||
color: $grey-40;
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
pointer-events: none;
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.inline-composer .link-fields {
|
||||
padding: 0 $unit-3x;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-2x;
|
||||
margin-top: $unit-2x;
|
||||
}
|
||||
|
||||
.inline-composer .composer-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: $unit-2x $unit-3x;
|
||||
border-top: 1px solid $grey-80;
|
||||
background-color: $grey-90;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { isMac } from '../utils.js';
|
||||
import type { EdraCommandGroup } from './types.js';
|
||||
import { isMac } from '../utils.js'
|
||||
import type { EdraCommandGroup } from './types.js'
|
||||
|
||||
export const commands: Record<string, EdraCommandGroup> = {
|
||||
'undo-redo': {
|
||||
|
|
@ -12,7 +12,7 @@ export const commands: Record<string, EdraCommandGroup> = {
|
|||
label: 'Undo',
|
||||
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Z`],
|
||||
action: (editor) => {
|
||||
editor.chain().focus().undo().run();
|
||||
editor.chain().focus().undo().run()
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -21,7 +21,7 @@ export const commands: Record<string, EdraCommandGroup> = {
|
|||
label: 'Redo',
|
||||
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Y`, `${isMac ? 'Cmd' : 'Ctrl'}+Shift+Z`],
|
||||
action: (editor) => {
|
||||
editor.chain().focus().redo().run();
|
||||
editor.chain().focus().redo().run()
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
@ -36,7 +36,7 @@ export const commands: Record<string, EdraCommandGroup> = {
|
|||
label: 'Heading 1',
|
||||
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Alt+1`],
|
||||
action: (editor) => {
|
||||
editor.chain().focus().toggleHeading({ level: 1 }).run();
|
||||
editor.chain().focus().toggleHeading({ level: 1 }).run()
|
||||
},
|
||||
isActive: (editor) => editor.isActive('heading', { level: 1 })
|
||||
},
|
||||
|
|
@ -46,7 +46,7 @@ export const commands: Record<string, EdraCommandGroup> = {
|
|||
label: 'Heading 2',
|
||||
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Alt+2`],
|
||||
action: (editor) => {
|
||||
editor.chain().focus().toggleHeading({ level: 2 }).run();
|
||||
editor.chain().focus().toggleHeading({ level: 2 }).run()
|
||||
},
|
||||
isActive: (editor) => editor.isActive('heading', { level: 2 })
|
||||
},
|
||||
|
|
@ -56,7 +56,7 @@ export const commands: Record<string, EdraCommandGroup> = {
|
|||
label: 'Heading 3',
|
||||
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Alt+3`],
|
||||
action: (editor) => {
|
||||
editor.chain().focus().toggleHeading({ level: 3 }).run();
|
||||
editor.chain().focus().toggleHeading({ level: 3 }).run()
|
||||
},
|
||||
isActive: (editor) => editor.isActive('heading', { level: 3 })
|
||||
}
|
||||
|
|
@ -72,8 +72,8 @@ export const commands: Record<string, EdraCommandGroup> = {
|
|||
label: 'Link',
|
||||
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+K`],
|
||||
action: (editor) => {
|
||||
const href = prompt('Enter the URL of the link:');
|
||||
if (href !== null) editor.chain().focus().setLink({ href, target: '_blank' }).run();
|
||||
const href = prompt('Enter the URL of the link:')
|
||||
if (href !== null) editor.chain().focus().setLink({ href, target: '_blank' }).run()
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -82,7 +82,7 @@ export const commands: Record<string, EdraCommandGroup> = {
|
|||
label: 'Bold',
|
||||
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+B`],
|
||||
action: (editor) => {
|
||||
editor.chain().focus().toggleBold().run();
|
||||
editor.chain().focus().toggleBold().run()
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -91,7 +91,7 @@ export const commands: Record<string, EdraCommandGroup> = {
|
|||
label: 'Italic',
|
||||
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+I`],
|
||||
action: (editor) => {
|
||||
editor.chain().focus().toggleItalic().run();
|
||||
editor.chain().focus().toggleItalic().run()
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -100,7 +100,7 @@ export const commands: Record<string, EdraCommandGroup> = {
|
|||
label: 'Underline',
|
||||
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+U`],
|
||||
action: (editor) => {
|
||||
editor.chain().focus().toggleUnderline().run();
|
||||
editor.chain().focus().toggleUnderline().run()
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -109,7 +109,7 @@ export const commands: Record<string, EdraCommandGroup> = {
|
|||
label: 'Strikethrough',
|
||||
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Shift+S`],
|
||||
action: (editor) => {
|
||||
editor.chain().focus().toggleStrike().run();
|
||||
editor.chain().focus().toggleStrike().run()
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -118,7 +118,7 @@ export const commands: Record<string, EdraCommandGroup> = {
|
|||
label: 'Blockquote',
|
||||
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Shift+B`],
|
||||
action: (editor) => {
|
||||
editor.chain().focus().toggleBlockquote().run();
|
||||
editor.chain().focus().toggleBlockquote().run()
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -127,7 +127,7 @@ export const commands: Record<string, EdraCommandGroup> = {
|
|||
label: 'Superscript',
|
||||
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Period`],
|
||||
action: (editor) => {
|
||||
editor.chain().focus().toggleSuperscript().run();
|
||||
editor.chain().focus().toggleSuperscript().run()
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -136,7 +136,7 @@ export const commands: Record<string, EdraCommandGroup> = {
|
|||
label: 'Subscript',
|
||||
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Comma`],
|
||||
action: (editor) => {
|
||||
editor.chain().focus().toggleSubscript().run();
|
||||
editor.chain().focus().toggleSubscript().run()
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -145,7 +145,7 @@ export const commands: Record<string, EdraCommandGroup> = {
|
|||
label: 'Code',
|
||||
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+E`],
|
||||
action: (editor) => {
|
||||
editor.chain().focus().toggleCode().run();
|
||||
editor.chain().focus().toggleCode().run()
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -154,7 +154,7 @@ export const commands: Record<string, EdraCommandGroup> = {
|
|||
label: 'Code Block',
|
||||
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Alt+C`],
|
||||
action: (editor) => {
|
||||
editor.chain().focus().toggleCodeBlock().run();
|
||||
editor.chain().focus().toggleCodeBlock().run()
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
@ -169,7 +169,7 @@ export const commands: Record<string, EdraCommandGroup> = {
|
|||
label: 'Align Left',
|
||||
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Shift+L`],
|
||||
action: (editor) => {
|
||||
editor.chain().focus().setTextAlign('left').run();
|
||||
editor.chain().focus().setTextAlign('left').run()
|
||||
},
|
||||
isActive: (editor) => editor.isActive({ textAlign: 'left' })
|
||||
},
|
||||
|
|
@ -179,7 +179,7 @@ export const commands: Record<string, EdraCommandGroup> = {
|
|||
label: 'Align Center',
|
||||
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Shift+E`],
|
||||
action: (editor) => {
|
||||
editor.chain().focus().setTextAlign('center').run();
|
||||
editor.chain().focus().setTextAlign('center').run()
|
||||
},
|
||||
isActive: (editor) => editor.isActive({ textAlign: 'center' })
|
||||
},
|
||||
|
|
@ -189,7 +189,7 @@ export const commands: Record<string, EdraCommandGroup> = {
|
|||
label: 'Align Right',
|
||||
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Shift+R`],
|
||||
action: (editor) => {
|
||||
editor.chain().focus().setTextAlign('right').run();
|
||||
editor.chain().focus().setTextAlign('right').run()
|
||||
},
|
||||
isActive: (editor) => editor.isActive({ textAlign: 'right' })
|
||||
},
|
||||
|
|
@ -199,7 +199,7 @@ export const commands: Record<string, EdraCommandGroup> = {
|
|||
label: 'Align Justify',
|
||||
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Shift+J`],
|
||||
action: (editor) => {
|
||||
editor.chain().focus().setTextAlign('justify').run();
|
||||
editor.chain().focus().setTextAlign('justify').run()
|
||||
},
|
||||
isActive: (editor) => editor.isActive({ textAlign: 'justify' })
|
||||
}
|
||||
|
|
@ -215,7 +215,7 @@ export const commands: Record<string, EdraCommandGroup> = {
|
|||
label: 'Bullet List',
|
||||
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Shift+8`],
|
||||
action: (editor) => {
|
||||
editor.chain().focus().toggleBulletList().run();
|
||||
editor.chain().focus().toggleBulletList().run()
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -224,7 +224,7 @@ export const commands: Record<string, EdraCommandGroup> = {
|
|||
label: 'Ordered List',
|
||||
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Shift+7`],
|
||||
action: (editor) => {
|
||||
editor.chain().focus().toggleOrderedList().run();
|
||||
editor.chain().focus().toggleOrderedList().run()
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -233,7 +233,7 @@ export const commands: Record<string, EdraCommandGroup> = {
|
|||
label: 'Task List',
|
||||
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Shift+9`],
|
||||
action: (editor) => {
|
||||
editor.chain().focus().toggleTaskList().run();
|
||||
editor.chain().focus().toggleTaskList().run()
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
@ -247,7 +247,7 @@ export const commands: Record<string, EdraCommandGroup> = {
|
|||
name: 'audio-placeholder',
|
||||
label: 'Audio',
|
||||
action: (editor) => {
|
||||
editor.chain().focus().insertAudioPlaceholder().run();
|
||||
editor.chain().focus().insertAudioPlaceholder().run()
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -255,7 +255,7 @@ export const commands: Record<string, EdraCommandGroup> = {
|
|||
name: 'image-placeholder',
|
||||
label: 'Image',
|
||||
action: (editor) => {
|
||||
editor.chain().focus().insertImagePlaceholder().run();
|
||||
editor.chain().focus().insertImagePlaceholder().run()
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -263,7 +263,7 @@ export const commands: Record<string, EdraCommandGroup> = {
|
|||
name: 'video-placeholder',
|
||||
label: 'Video',
|
||||
action: (editor) => {
|
||||
editor.chain().focus().insertVideoPlaceholder().run();
|
||||
editor.chain().focus().insertVideoPlaceholder().run()
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -271,7 +271,7 @@ export const commands: Record<string, EdraCommandGroup> = {
|
|||
name: 'iframe-placeholder',
|
||||
label: 'IFrame',
|
||||
action: (editor) => {
|
||||
editor.chain().focus().insertIFramePlaceholder().run();
|
||||
editor.chain().focus().insertIFramePlaceholder().run()
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
@ -286,7 +286,7 @@ export const commands: Record<string, EdraCommandGroup> = {
|
|||
label: 'Color',
|
||||
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Shift+C`],
|
||||
action: (editor) => {
|
||||
editor.chain().focus().unsetColor().run();
|
||||
editor.chain().focus().unsetColor().run()
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -295,7 +295,7 @@ export const commands: Record<string, EdraCommandGroup> = {
|
|||
label: 'Highlight',
|
||||
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Shift+H`],
|
||||
action: (editor) => {
|
||||
editor.chain().focus().toggleHighlight().run();
|
||||
editor.chain().focus().toggleHighlight().run()
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
@ -310,8 +310,8 @@ export const commands: Record<string, EdraCommandGroup> = {
|
|||
label: 'Table',
|
||||
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Shift+T`],
|
||||
action: (editor) => {
|
||||
if (editor.isActive('table')) editor.chain().focus().deleteTable().run();
|
||||
else editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: false }).run();
|
||||
if (editor.isActive('table')) editor.chain().focus().deleteTable().run()
|
||||
else editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: false }).run()
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
@ -325,9 +325,9 @@ export const commands: Record<string, EdraCommandGroup> = {
|
|||
name: 'font increment',
|
||||
label: 'Increase Font Size',
|
||||
action: (editor) => {
|
||||
let currentFontSize = parseInt(editor.getAttributes('textStyle').fontSize ?? '16px');
|
||||
currentFontSize++;
|
||||
editor.chain().focus().setFontSize(`${currentFontSize}px`).run();
|
||||
let currentFontSize = parseInt(editor.getAttributes('textStyle').fontSize ?? '16px')
|
||||
currentFontSize++
|
||||
editor.chain().focus().setFontSize(`${currentFontSize}px`).run()
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -335,11 +335,11 @@ export const commands: Record<string, EdraCommandGroup> = {
|
|||
name: 'font decrement',
|
||||
label: 'Decrease Font Size',
|
||||
action: (editor) => {
|
||||
let currentFontSize = parseInt(editor.getAttributes('textStyle').fontSize ?? '16px');
|
||||
currentFontSize--;
|
||||
editor.chain().focus().setFontSize(`${currentFontSize}px`).run();
|
||||
let currentFontSize = parseInt(editor.getAttributes('textStyle').fontSize ?? '16px')
|
||||
currentFontSize--
|
||||
editor.chain().focus().setFontSize(`${currentFontSize}px`).run()
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,21 @@
|
|||
import type { Editor } from '@tiptap/core';
|
||||
import type { icons } from 'lucide-svelte';
|
||||
import type { Editor } from '@tiptap/core'
|
||||
import type { icons } from 'lucide-svelte'
|
||||
|
||||
export interface EdraCommand {
|
||||
iconName: keyof typeof icons;
|
||||
name: string;
|
||||
label: string;
|
||||
shortCuts?: string[];
|
||||
action: (editor: Editor) => void;
|
||||
isActive?: (editor: Editor) => boolean;
|
||||
iconName: keyof typeof icons
|
||||
name: string
|
||||
label: string
|
||||
shortCuts?: string[]
|
||||
action: (editor: Editor) => void
|
||||
isActive?: (editor: Editor) => boolean
|
||||
}
|
||||
|
||||
export interface EdraCommandShortCuts {
|
||||
key: string;
|
||||
key: string
|
||||
}
|
||||
|
||||
export interface EdraCommandGroup {
|
||||
name: string;
|
||||
label: string;
|
||||
commands: EdraCommand[];
|
||||
name: string
|
||||
label: string
|
||||
commands: EdraCommand[]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,16 @@
|
|||
<script lang="ts">
|
||||
import type { Editor } from '@tiptap/core';
|
||||
import { onMount } from 'svelte';
|
||||
import GripVertical from 'lucide-svelte/icons/grip-vertical';
|
||||
import { DragHandlePlugin } from './extensions/drag-handle/index.js';
|
||||
import type { Editor } from '@tiptap/core'
|
||||
import { onMount } from 'svelte'
|
||||
import GripVertical from 'lucide-svelte/icons/grip-vertical'
|
||||
import { DragHandlePlugin } from './extensions/drag-handle/index.js'
|
||||
|
||||
interface Props {
|
||||
editor: Editor;
|
||||
editor: Editor
|
||||
}
|
||||
|
||||
const { editor }: Props = $props();
|
||||
const { editor }: Props = $props()
|
||||
|
||||
const pluginKey = 'globalDragHandle';
|
||||
const pluginKey = 'globalDragHandle'
|
||||
|
||||
onMount(() => {
|
||||
const plugin = DragHandlePlugin({
|
||||
|
|
@ -20,10 +20,10 @@
|
|||
dragHandleSelector: '.drag-handle',
|
||||
excludedTags: ['pre', 'code', 'table p'],
|
||||
customNodes: []
|
||||
});
|
||||
editor.registerPlugin(plugin);
|
||||
return () => editor.unregisterPlugin(pluginKey);
|
||||
});
|
||||
})
|
||||
editor.registerPlugin(plugin)
|
||||
return () => editor.unregisterPlugin(pluginKey)
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="drag-handle">
|
||||
|
|
|
|||
|
|
@ -1,28 +1,28 @@
|
|||
import { Editor, type Content, type EditorOptions, type Extensions } from '@tiptap/core';
|
||||
import Color from '@tiptap/extension-color';
|
||||
import Link from '@tiptap/extension-link';
|
||||
import Subscript from '@tiptap/extension-subscript';
|
||||
import Superscript from '@tiptap/extension-superscript';
|
||||
import TaskItem from '@tiptap/extension-task-item';
|
||||
import TaskList from '@tiptap/extension-task-list';
|
||||
import TextAlign from '@tiptap/extension-text-align';
|
||||
import TextStyle from '@tiptap/extension-text-style';
|
||||
import Typography from '@tiptap/extension-typography';
|
||||
import Underline from '@tiptap/extension-underline';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import Highlight from '@tiptap/extension-highlight';
|
||||
import Text from '@tiptap/extension-text';
|
||||
import { SmilieReplacer } from './extensions/SmilieReplacer.js';
|
||||
import { ColorHighlighter } from './extensions/ColorHighlighter.js';
|
||||
import AutoJoiner from 'tiptap-extension-auto-joiner';
|
||||
import { MathExtension } from '@aarkue/tiptap-math-extension';
|
||||
import { Table, TableCell, TableHeader, TableRow } from './extensions/table/index.js';
|
||||
import FontSize from './extensions/FontSize.js';
|
||||
import Placeholder from '@tiptap/extension-placeholder';
|
||||
import CharacterCount from '@tiptap/extension-character-count';
|
||||
import SearchAndReplace from './extensions/FindAndReplace.js';
|
||||
import { getHandlePaste } from './utils.js';
|
||||
import { Markdown } from 'tiptap-markdown';
|
||||
import { Editor, type Content, type EditorOptions, type Extensions } from '@tiptap/core'
|
||||
import Color from '@tiptap/extension-color'
|
||||
import Link from '@tiptap/extension-link'
|
||||
import Subscript from '@tiptap/extension-subscript'
|
||||
import Superscript from '@tiptap/extension-superscript'
|
||||
import TaskItem from '@tiptap/extension-task-item'
|
||||
import TaskList from '@tiptap/extension-task-list'
|
||||
import TextAlign from '@tiptap/extension-text-align'
|
||||
import TextStyle from '@tiptap/extension-text-style'
|
||||
import Typography from '@tiptap/extension-typography'
|
||||
import Underline from '@tiptap/extension-underline'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import Highlight from '@tiptap/extension-highlight'
|
||||
import Text from '@tiptap/extension-text'
|
||||
import { SmilieReplacer } from './extensions/SmilieReplacer.js'
|
||||
import { ColorHighlighter } from './extensions/ColorHighlighter.js'
|
||||
import AutoJoiner from 'tiptap-extension-auto-joiner'
|
||||
import { MathExtension } from '@aarkue/tiptap-math-extension'
|
||||
import { Table, TableCell, TableHeader, TableRow } from './extensions/table/index.js'
|
||||
import FontSize from './extensions/FontSize.js'
|
||||
import Placeholder from '@tiptap/extension-placeholder'
|
||||
import CharacterCount from '@tiptap/extension-character-count'
|
||||
import SearchAndReplace from './extensions/FindAndReplace.js'
|
||||
import { getHandlePaste } from './utils.js'
|
||||
import { Markdown } from 'tiptap-markdown'
|
||||
|
||||
export const initiateEditor = (
|
||||
element?: HTMLElement,
|
||||
|
|
@ -104,11 +104,11 @@ export const initiateEditor = (
|
|||
// Use different placeholders depending on the node type:
|
||||
placeholder: ({ node }) => {
|
||||
if (node.type.name === 'heading') {
|
||||
return 'What’s the title?';
|
||||
return 'What’s the title?'
|
||||
} else if (node.type.name === 'paragraph') {
|
||||
return 'Press / or write something ...';
|
||||
return 'Press / or write something ...'
|
||||
}
|
||||
return '';
|
||||
return ''
|
||||
}
|
||||
}),
|
||||
CharacterCount.configure({
|
||||
|
|
@ -120,12 +120,12 @@ export const initiateEditor = (
|
|||
],
|
||||
autofocus: true,
|
||||
...options
|
||||
});
|
||||
})
|
||||
|
||||
editor.setOptions({
|
||||
editorProps: {
|
||||
handlePaste: getHandlePaste(editor)
|
||||
}
|
||||
});
|
||||
return editor;
|
||||
};
|
||||
})
|
||||
return editor
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { Extension } from '@tiptap/core';
|
||||
import { Plugin } from '@tiptap/pm/state';
|
||||
import { Extension } from '@tiptap/core'
|
||||
import { Plugin } from '@tiptap/pm/state'
|
||||
|
||||
import { findColors } from '../utils.js';
|
||||
import { findColors } from '../utils.js'
|
||||
|
||||
export const ColorHighlighter = Extension.create({
|
||||
name: 'colorHighlighter',
|
||||
|
|
@ -11,18 +11,18 @@ export const ColorHighlighter = Extension.create({
|
|||
new Plugin({
|
||||
state: {
|
||||
init(_, { doc }) {
|
||||
return findColors(doc);
|
||||
return findColors(doc)
|
||||
},
|
||||
apply(transaction, oldState) {
|
||||
return transaction.docChanged ? findColors(transaction.doc) : oldState;
|
||||
return transaction.docChanged ? findColors(transaction.doc) : oldState
|
||||
}
|
||||
},
|
||||
props: {
|
||||
decorations(state) {
|
||||
return this.getState(state);
|
||||
return this.getState(state)
|
||||
}
|
||||
}
|
||||
})
|
||||
];
|
||||
]
|
||||
}
|
||||
});
|
||||
})
|
||||
|
|
|
|||
|
|
@ -20,10 +20,10 @@
|
|||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
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 { 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'
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
|
|
@ -31,54 +31,54 @@ declare module '@tiptap/core' {
|
|||
/**
|
||||
* @description Set search term in extension.
|
||||
*/
|
||||
setSearchTerm: (searchTerm: string) => ReturnType;
|
||||
setSearchTerm: (searchTerm: string) => ReturnType
|
||||
/**
|
||||
* @description Set replace term in extension.
|
||||
*/
|
||||
setReplaceTerm: (replaceTerm: string) => ReturnType;
|
||||
setReplaceTerm: (replaceTerm: string) => ReturnType
|
||||
/**
|
||||
* @description Set case sensitivity in extension.
|
||||
*/
|
||||
setCaseSensitive: (caseSensitive: boolean) => ReturnType;
|
||||
setCaseSensitive: (caseSensitive: boolean) => ReturnType
|
||||
/**
|
||||
* @description Reset current search result to first instance.
|
||||
*/
|
||||
resetIndex: () => ReturnType;
|
||||
resetIndex: () => ReturnType
|
||||
/**
|
||||
* @description Find next instance of search result.
|
||||
*/
|
||||
nextSearchResult: () => ReturnType;
|
||||
nextSearchResult: () => ReturnType
|
||||
/**
|
||||
* @description Find previous instance of search result.
|
||||
*/
|
||||
previousSearchResult: () => ReturnType;
|
||||
previousSearchResult: () => ReturnType
|
||||
/**
|
||||
* @description Replace first instance of search result with given replace term.
|
||||
*/
|
||||
replace: () => ReturnType;
|
||||
replace: () => ReturnType
|
||||
/**
|
||||
* @description Replace all instances of search result with given replace term.
|
||||
*/
|
||||
replaceAll: () => ReturnType;
|
||||
};
|
||||
replaceAll: () => ReturnType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface TextNodesWithPosition {
|
||||
text: string;
|
||||
pos: number;
|
||||
text: string
|
||||
pos: number
|
||||
}
|
||||
|
||||
const getRegex = (s: string, disableRegex: boolean, caseSensitive: boolean): RegExp => {
|
||||
return RegExp(
|
||||
disableRegex ? s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') : s,
|
||||
caseSensitive ? 'gu' : 'gui'
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
interface ProcessedSearches {
|
||||
decorationsToReturn: DecorationSet;
|
||||
results: Range[];
|
||||
decorationsToReturn: DecorationSet
|
||||
results: Range[]
|
||||
}
|
||||
|
||||
function processSearches(
|
||||
|
|
@ -87,17 +87,17 @@ function processSearches(
|
|||
searchResultClass: string,
|
||||
resultIndex: number
|
||||
): ProcessedSearches {
|
||||
const decorations: Decoration[] = [];
|
||||
const results: Range[] = [];
|
||||
const decorations: Decoration[] = []
|
||||
const results: Range[] = []
|
||||
|
||||
let textNodesWithPosition: TextNodesWithPosition[] = [];
|
||||
let index = 0;
|
||||
let textNodesWithPosition: TextNodesWithPosition[] = []
|
||||
let index = 0
|
||||
|
||||
if (!searchTerm) {
|
||||
return {
|
||||
decorationsToReturn: DecorationSet.empty,
|
||||
results: []
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
doc?.descendants((node, pos) => {
|
||||
|
|
@ -106,51 +106,51 @@ function processSearches(
|
|||
textNodesWithPosition[index] = {
|
||||
text: textNodesWithPosition[index].text + node.text,
|
||||
pos: textNodesWithPosition[index].pos
|
||||
};
|
||||
}
|
||||
} else {
|
||||
textNodesWithPosition[index] = {
|
||||
text: `${node.text}`,
|
||||
pos
|
||||
};
|
||||
}
|
||||
}
|
||||
} else {
|
||||
index += 1;
|
||||
index += 1
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
textNodesWithPosition = textNodesWithPosition.filter(Boolean);
|
||||
textNodesWithPosition = textNodesWithPosition.filter(Boolean)
|
||||
|
||||
for (const element of textNodesWithPosition) {
|
||||
const { text, pos } = element;
|
||||
const matches = Array.from(text.matchAll(searchTerm)).filter(([matchText]) => matchText.trim());
|
||||
const { text, pos } = element
|
||||
const matches = Array.from(text.matchAll(searchTerm)).filter(([matchText]) => matchText.trim())
|
||||
|
||||
for (const m of matches) {
|
||||
if (m[0] === '') break;
|
||||
if (m[0] === '') break
|
||||
|
||||
if (m.index !== undefined) {
|
||||
results.push({
|
||||
from: pos + m.index,
|
||||
to: pos + m.index + m[0].length
|
||||
});
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < results.length; i += 1) {
|
||||
const r = results[i];
|
||||
const r = results[i]
|
||||
const className =
|
||||
i === resultIndex ? `${searchResultClass} ${searchResultClass}-current` : searchResultClass;
|
||||
i === resultIndex ? `${searchResultClass} ${searchResultClass}-current` : searchResultClass
|
||||
const decoration: Decoration = Decoration.inline(r.from, r.to, {
|
||||
class: className
|
||||
});
|
||||
})
|
||||
|
||||
decorations.push(decoration);
|
||||
decorations.push(decoration)
|
||||
}
|
||||
|
||||
return {
|
||||
decorationsToReturn: DecorationSet.create(doc, decorations),
|
||||
results
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const replace = (
|
||||
|
|
@ -158,14 +158,14 @@ const replace = (
|
|||
results: Range[],
|
||||
{ state, dispatch }: { state: EditorState; dispatch: Dispatch }
|
||||
) => {
|
||||
const firstResult = results[0];
|
||||
const firstResult = results[0]
|
||||
|
||||
if (!firstResult) return;
|
||||
if (!firstResult) return
|
||||
|
||||
const { from, to } = results[0];
|
||||
const { from, to } = results[0]
|
||||
|
||||
if (dispatch) dispatch(state.tr.insertText(replaceTerm, from, to));
|
||||
};
|
||||
if (dispatch) dispatch(state.tr.insertText(replaceTerm, from, to))
|
||||
}
|
||||
|
||||
const rebaseNextResult = (
|
||||
replaceTerm: string,
|
||||
|
|
@ -173,69 +173,69 @@ const rebaseNextResult = (
|
|||
lastOffset: number,
|
||||
results: Range[]
|
||||
): [number, Range[]] | null => {
|
||||
const nextIndex = index + 1;
|
||||
const nextIndex = index + 1
|
||||
|
||||
if (!results[nextIndex]) return null;
|
||||
if (!results[nextIndex]) return null
|
||||
|
||||
const { from: currentFrom, to: currentTo } = results[index];
|
||||
const { from: currentFrom, to: currentTo } = results[index]
|
||||
|
||||
const offset = currentTo - currentFrom - replaceTerm.length + lastOffset;
|
||||
const offset = currentTo - currentFrom - replaceTerm.length + lastOffset
|
||||
|
||||
const { from, to } = results[nextIndex];
|
||||
const { from, to } = results[nextIndex]
|
||||
|
||||
results[nextIndex] = {
|
||||
to: to - offset,
|
||||
from: from - offset
|
||||
};
|
||||
}
|
||||
|
||||
return [offset, results];
|
||||
};
|
||||
return [offset, results]
|
||||
}
|
||||
|
||||
const replaceAll = (
|
||||
replaceTerm: string,
|
||||
results: Range[],
|
||||
{ tr, dispatch }: { tr: Transaction; dispatch: Dispatch }
|
||||
) => {
|
||||
let offset = 0;
|
||||
let offset = 0
|
||||
|
||||
let resultsCopy = results.slice();
|
||||
let resultsCopy = results.slice()
|
||||
|
||||
if (!resultsCopy.length) return;
|
||||
if (!resultsCopy.length) return
|
||||
|
||||
for (let i = 0; i < resultsCopy.length; i += 1) {
|
||||
const { from, to } = resultsCopy[i];
|
||||
const { from, to } = resultsCopy[i]
|
||||
|
||||
tr.insertText(replaceTerm, from, to);
|
||||
tr.insertText(replaceTerm, from, to)
|
||||
|
||||
const rebaseNextResultResponse = rebaseNextResult(replaceTerm, i, offset, resultsCopy);
|
||||
const rebaseNextResultResponse = rebaseNextResult(replaceTerm, i, offset, resultsCopy)
|
||||
|
||||
if (!rebaseNextResultResponse) continue;
|
||||
if (!rebaseNextResultResponse) continue
|
||||
|
||||
offset = rebaseNextResultResponse[0];
|
||||
resultsCopy = rebaseNextResultResponse[1];
|
||||
offset = rebaseNextResultResponse[0]
|
||||
resultsCopy = rebaseNextResultResponse[1]
|
||||
}
|
||||
|
||||
if (dispatch) {
|
||||
dispatch(tr);
|
||||
dispatch(tr)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const searchAndReplacePluginKey = new PluginKey('searchAndReplacePlugin');
|
||||
export const searchAndReplacePluginKey = new PluginKey('searchAndReplacePlugin')
|
||||
|
||||
export interface SearchAndReplaceOptions {
|
||||
searchResultClass: string;
|
||||
disableRegex: boolean;
|
||||
searchResultClass: string
|
||||
disableRegex: boolean
|
||||
}
|
||||
|
||||
export interface SearchAndReplaceStorage {
|
||||
searchTerm: string;
|
||||
replaceTerm: string;
|
||||
results: Range[];
|
||||
lastSearchTerm: string;
|
||||
caseSensitive: boolean;
|
||||
lastCaseSensitive: boolean;
|
||||
resultIndex: number;
|
||||
lastResultIndex: number;
|
||||
searchTerm: string
|
||||
replaceTerm: string
|
||||
results: Range[]
|
||||
lastSearchTerm: string
|
||||
caseSensitive: boolean
|
||||
lastCaseSensitive: boolean
|
||||
resultIndex: number
|
||||
lastResultIndex: number
|
||||
}
|
||||
|
||||
export const SearchAndReplace = Extension.create<SearchAndReplaceOptions, SearchAndReplaceStorage>({
|
||||
|
|
@ -245,7 +245,7 @@ export const SearchAndReplace = Extension.create<SearchAndReplaceOptions, Search
|
|||
return {
|
||||
searchResultClass: 'search-result',
|
||||
disableRegex: true
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
|
|
@ -258,7 +258,7 @@ export const SearchAndReplace = Extension.create<SearchAndReplaceOptions, Search
|
|||
lastCaseSensitive: false,
|
||||
resultIndex: 0,
|
||||
lastResultIndex: 0
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
|
|
@ -266,90 +266,90 @@ export const SearchAndReplace = Extension.create<SearchAndReplaceOptions, Search
|
|||
setSearchTerm:
|
||||
(searchTerm: string) =>
|
||||
({ editor }) => {
|
||||
editor.storage.searchAndReplace.searchTerm = searchTerm;
|
||||
editor.storage.searchAndReplace.searchTerm = searchTerm
|
||||
|
||||
return false;
|
||||
return false
|
||||
},
|
||||
setReplaceTerm:
|
||||
(replaceTerm: string) =>
|
||||
({ editor }) => {
|
||||
editor.storage.searchAndReplace.replaceTerm = replaceTerm;
|
||||
editor.storage.searchAndReplace.replaceTerm = replaceTerm
|
||||
|
||||
return false;
|
||||
return false
|
||||
},
|
||||
setCaseSensitive:
|
||||
(caseSensitive: boolean) =>
|
||||
({ editor }) => {
|
||||
editor.storage.searchAndReplace.caseSensitive = caseSensitive;
|
||||
editor.storage.searchAndReplace.caseSensitive = caseSensitive
|
||||
|
||||
return false;
|
||||
return false
|
||||
},
|
||||
resetIndex:
|
||||
() =>
|
||||
({ editor }) => {
|
||||
editor.storage.searchAndReplace.resultIndex = 0;
|
||||
editor.storage.searchAndReplace.resultIndex = 0
|
||||
|
||||
return false;
|
||||
return false
|
||||
},
|
||||
nextSearchResult:
|
||||
() =>
|
||||
({ editor }) => {
|
||||
const { results, resultIndex } = editor.storage.searchAndReplace;
|
||||
const { results, resultIndex } = editor.storage.searchAndReplace
|
||||
|
||||
const nextIndex = resultIndex + 1;
|
||||
const nextIndex = resultIndex + 1
|
||||
|
||||
if (results[nextIndex]) {
|
||||
editor.storage.searchAndReplace.resultIndex = nextIndex;
|
||||
editor.storage.searchAndReplace.resultIndex = nextIndex
|
||||
} else {
|
||||
editor.storage.searchAndReplace.resultIndex = 0;
|
||||
editor.storage.searchAndReplace.resultIndex = 0
|
||||
}
|
||||
|
||||
return false;
|
||||
return false
|
||||
},
|
||||
previousSearchResult:
|
||||
() =>
|
||||
({ editor }) => {
|
||||
const { results, resultIndex } = editor.storage.searchAndReplace;
|
||||
const { results, resultIndex } = editor.storage.searchAndReplace
|
||||
|
||||
const prevIndex = resultIndex - 1;
|
||||
const prevIndex = resultIndex - 1
|
||||
|
||||
if (results[prevIndex]) {
|
||||
editor.storage.searchAndReplace.resultIndex = prevIndex;
|
||||
editor.storage.searchAndReplace.resultIndex = prevIndex
|
||||
} else {
|
||||
editor.storage.searchAndReplace.resultIndex = results.length - 1;
|
||||
editor.storage.searchAndReplace.resultIndex = results.length - 1
|
||||
}
|
||||
|
||||
return false;
|
||||
return false
|
||||
},
|
||||
replace:
|
||||
() =>
|
||||
({ editor, state, dispatch }) => {
|
||||
const { replaceTerm, results } = editor.storage.searchAndReplace;
|
||||
const { replaceTerm, results } = editor.storage.searchAndReplace
|
||||
|
||||
replace(replaceTerm, results, { state, dispatch });
|
||||
replace(replaceTerm, results, { state, dispatch })
|
||||
|
||||
return false;
|
||||
return false
|
||||
},
|
||||
replaceAll:
|
||||
() =>
|
||||
({ editor, tr, dispatch }) => {
|
||||
const { replaceTerm, results } = editor.storage.searchAndReplace;
|
||||
const { replaceTerm, results } = editor.storage.searchAndReplace
|
||||
|
||||
replaceAll(replaceTerm, results, { tr, dispatch });
|
||||
replaceAll(replaceTerm, results, { tr, dispatch })
|
||||
|
||||
return false;
|
||||
return false
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
const editor = this.editor;
|
||||
const { searchResultClass, disableRegex } = this.options;
|
||||
const editor = this.editor
|
||||
const { searchResultClass, disableRegex } = this.options
|
||||
|
||||
const setLastSearchTerm = (t: string) => (editor.storage.searchAndReplace.lastSearchTerm = t);
|
||||
const setLastSearchTerm = (t: string) => (editor.storage.searchAndReplace.lastSearchTerm = t)
|
||||
const setLastCaseSensitive = (t: boolean) =>
|
||||
(editor.storage.searchAndReplace.lastCaseSensitive = t);
|
||||
const setLastResultIndex = (t: number) => (editor.storage.searchAndReplace.lastResultIndex = t);
|
||||
(editor.storage.searchAndReplace.lastCaseSensitive = t)
|
||||
const setLastResultIndex = (t: number) => (editor.storage.searchAndReplace.lastResultIndex = t)
|
||||
|
||||
return [
|
||||
new Plugin({
|
||||
|
|
@ -364,7 +364,7 @@ export const SearchAndReplace = Extension.create<SearchAndReplaceOptions, Search
|
|||
lastCaseSensitive,
|
||||
resultIndex,
|
||||
lastResultIndex
|
||||
} = editor.storage.searchAndReplace;
|
||||
} = editor.storage.searchAndReplace
|
||||
|
||||
if (
|
||||
!docChanged &&
|
||||
|
|
@ -372,15 +372,15 @@ export const SearchAndReplace = Extension.create<SearchAndReplaceOptions, Search
|
|||
lastCaseSensitive === caseSensitive &&
|
||||
lastResultIndex === resultIndex
|
||||
)
|
||||
return oldState;
|
||||
return oldState
|
||||
|
||||
setLastSearchTerm(searchTerm);
|
||||
setLastCaseSensitive(caseSensitive);
|
||||
setLastResultIndex(resultIndex);
|
||||
setLastSearchTerm(searchTerm)
|
||||
setLastCaseSensitive(caseSensitive)
|
||||
setLastResultIndex(resultIndex)
|
||||
|
||||
if (!searchTerm) {
|
||||
editor.storage.searchAndReplace.results = [];
|
||||
return DecorationSet.empty;
|
||||
editor.storage.searchAndReplace.results = []
|
||||
return DecorationSet.empty
|
||||
}
|
||||
|
||||
const { decorationsToReturn, results } = processSearches(
|
||||
|
|
@ -388,21 +388,21 @@ export const SearchAndReplace = Extension.create<SearchAndReplaceOptions, Search
|
|||
getRegex(searchTerm, disableRegex, caseSensitive),
|
||||
searchResultClass,
|
||||
resultIndex
|
||||
);
|
||||
)
|
||||
|
||||
editor.storage.searchAndReplace.results = results;
|
||||
editor.storage.searchAndReplace.results = results
|
||||
|
||||
return decorationsToReturn;
|
||||
return decorationsToReturn
|
||||
}
|
||||
},
|
||||
props: {
|
||||
decorations(state) {
|
||||
return this.getState(state);
|
||||
return this.getState(state)
|
||||
}
|
||||
}
|
||||
})
|
||||
];
|
||||
]
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
export default SearchAndReplace;
|
||||
export default SearchAndReplace
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import { type Attributes, Extension } from '@tiptap/core';
|
||||
import '@tiptap/extension-text-style';
|
||||
import { type Attributes, Extension } from '@tiptap/core'
|
||||
import '@tiptap/extension-text-style'
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
fontSize: {
|
||||
setFontSize: (size: string) => ReturnType;
|
||||
unsetFontSize: () => ReturnType;
|
||||
};
|
||||
setFontSize: (size: string) => ReturnType
|
||||
unsetFontSize: () => ReturnType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -16,7 +16,7 @@ export const FontSize = Extension.create({
|
|||
addOptions() {
|
||||
return {
|
||||
types: ['textStyle']
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
addGlobalAttributes() {
|
||||
|
|
@ -34,17 +34,17 @@ export const FontSize = Extension.create({
|
|||
parseHTML: (element) => element.style.fontSize.replace(/['"]+/g, ''),
|
||||
renderHTML: (attributes) => {
|
||||
if (!attributes.fontSize) {
|
||||
return {};
|
||||
return {}
|
||||
}
|
||||
|
||||
return {
|
||||
style: `font-size: ${attributes.fontSize}`
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
} as Attributes
|
||||
}
|
||||
];
|
||||
]
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
|
|
@ -57,8 +57,8 @@ export const FontSize = Extension.create({
|
|||
() =>
|
||||
({ chain }) =>
|
||||
chain().setMark('textStyle', { fontSize: null }).removeEmptyTextStyle().run()
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
export default FontSize;
|
||||
export default FontSize
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Extension, textInputRule } from '@tiptap/core';
|
||||
import { Extension, textInputRule } from '@tiptap/core'
|
||||
|
||||
export const SmilieReplacer = Extension.create({
|
||||
name: 'smilieReplacer',
|
||||
|
|
@ -128,6 +128,6 @@ export const SmilieReplacer = Extension.create({
|
|||
textInputRule({ find: /:@ $/, replace: '😠 ' }),
|
||||
textInputRule({ find: /<3 $/, replace: '❤️ ' }),
|
||||
textInputRule({ find: /\/shrug $/, replace: '¯\\_(ツ)_/¯' })
|
||||
];
|
||||
]
|
||||
}
|
||||
});
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { SvelteNodeViewRenderer } from 'svelte-tiptap';
|
||||
import { Audio } from './AudioExtension.js';
|
||||
import type { NodeViewProps } from '@tiptap/core';
|
||||
import type { Component } from 'svelte';
|
||||
import { SvelteNodeViewRenderer } from 'svelte-tiptap'
|
||||
import { Audio } from './AudioExtension.js'
|
||||
import type { NodeViewProps } from '@tiptap/core'
|
||||
import type { Component } from 'svelte'
|
||||
|
||||
export const AudioExtended = (content: Component<NodeViewProps>) =>
|
||||
Audio.extend({
|
||||
|
|
@ -25,10 +25,10 @@ export const AudioExtended = (content: Component<NodeViewProps>) =>
|
|||
align: {
|
||||
default: 'left'
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
addNodeView: () => {
|
||||
return SvelteNodeViewRenderer(content);
|
||||
return SvelteNodeViewRenderer(content)
|
||||
}
|
||||
});
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { Node, nodeInputRule } from '@tiptap/core';
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state';
|
||||
import { Node, nodeInputRule } from '@tiptap/core'
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
||||
|
||||
export interface AudioOptions {
|
||||
HTMLAttributes: Record<string, unknown>;
|
||||
HTMLAttributes: Record<string, unknown>
|
||||
}
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
|
|
@ -11,20 +11,20 @@ declare module '@tiptap/core' {
|
|||
/**
|
||||
* Set a audio node
|
||||
*/
|
||||
setAudio: (src: string) => ReturnType;
|
||||
setAudio: (src: string) => ReturnType
|
||||
/**
|
||||
* Toggle a audio
|
||||
*/
|
||||
toggleAudio: (src: string) => ReturnType;
|
||||
toggleAudio: (src: string) => ReturnType
|
||||
/**
|
||||
* Remove a audio
|
||||
*/
|
||||
removeAudio: () => ReturnType;
|
||||
};
|
||||
removeAudio: () => ReturnType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const AUDIO_INPUT_REGEX = /!\[(.+|:?)]\((\S+)(?:(?:\s+)["'](\S+)["'])?\)/;
|
||||
const AUDIO_INPUT_REGEX = /!\[(.+|:?)]\((\S+)(?:(?:\s+)["'](\S+)["'])?\)/
|
||||
|
||||
export const Audio = Node.create<AudioOptions>({
|
||||
name: 'audio',
|
||||
|
|
@ -35,7 +35,7 @@ export const Audio = Node.create<AudioOptions>({
|
|||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {}
|
||||
};
|
||||
}
|
||||
},
|
||||
addAttributes() {
|
||||
return {
|
||||
|
|
@ -44,7 +44,7 @@ export const Audio = Node.create<AudioOptions>({
|
|||
parseHTML: (el) => (el as HTMLSpanElement).getAttribute('src'),
|
||||
renderHTML: (attrs) => ({ src: attrs.src })
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
parseHTML() {
|
||||
return [
|
||||
|
|
@ -52,7 +52,7 @@ export const Audio = Node.create<AudioOptions>({
|
|||
tag: 'audio',
|
||||
getAttrs: (el) => ({ src: (el as HTMLAudioElement).getAttribute('src') })
|
||||
}
|
||||
];
|
||||
]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
|
|
@ -60,7 +60,7 @@ export const Audio = Node.create<AudioOptions>({
|
|||
'audio',
|
||||
{ controls: 'true', style: 'width: 100%;', ...HTMLAttributes },
|
||||
['source', HTMLAttributes]
|
||||
];
|
||||
]
|
||||
},
|
||||
addCommands() {
|
||||
return {
|
||||
|
|
@ -79,7 +79,7 @@ export const Audio = Node.create<AudioOptions>({
|
|||
() =>
|
||||
({ commands }) =>
|
||||
commands.deleteNode(this.name)
|
||||
};
|
||||
}
|
||||
},
|
||||
addInputRules() {
|
||||
return [
|
||||
|
|
@ -87,12 +87,12 @@ export const Audio = Node.create<AudioOptions>({
|
|||
find: AUDIO_INPUT_REGEX,
|
||||
type: this.type,
|
||||
getAttributes: (match) => {
|
||||
const [, , src] = match;
|
||||
const [, , src] = match
|
||||
|
||||
return { src };
|
||||
return { src }
|
||||
}
|
||||
})
|
||||
];
|
||||
]
|
||||
},
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
|
|
@ -105,43 +105,43 @@ export const Audio = Node.create<AudioOptions>({
|
|||
const {
|
||||
state: { schema, tr },
|
||||
dispatch
|
||||
} = view;
|
||||
} = view
|
||||
const hasFiles =
|
||||
event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length;
|
||||
event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length
|
||||
|
||||
if (!hasFiles) return false;
|
||||
if (!hasFiles) return false
|
||||
|
||||
const audios = Array.from(event.dataTransfer.files).filter((file) =>
|
||||
/audio/i.test(file.type)
|
||||
);
|
||||
)
|
||||
|
||||
if (audios.length === 0) return false;
|
||||
if (audios.length === 0) return false
|
||||
|
||||
event.preventDefault();
|
||||
event.preventDefault()
|
||||
|
||||
const coordinates = view.posAtCoords({ left: event.clientX, top: event.clientY });
|
||||
const coordinates = view.posAtCoords({ left: event.clientX, top: event.clientY })
|
||||
|
||||
audios.forEach((audio) => {
|
||||
const reader = new FileReader();
|
||||
const reader = new FileReader()
|
||||
|
||||
reader.onload = (readerEvent) => {
|
||||
const node = schema.nodes.audio.create({ src: readerEvent.target?.result });
|
||||
const node = schema.nodes.audio.create({ src: readerEvent.target?.result })
|
||||
|
||||
if (coordinates && typeof coordinates.pos === 'number') {
|
||||
const transaction = tr.insert(coordinates?.pos, node);
|
||||
const transaction = tr.insert(coordinates?.pos, node)
|
||||
|
||||
dispatch(transaction);
|
||||
dispatch(transaction)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
reader.readAsDataURL(audio);
|
||||
});
|
||||
reader.readAsDataURL(audio)
|
||||
})
|
||||
|
||||
return true;
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
];
|
||||
]
|
||||
}
|
||||
});
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
import { Editor, Node, mergeAttributes, type CommandProps, type NodeViewProps } from '@tiptap/core';
|
||||
import type { Component } from 'svelte';
|
||||
import { SvelteNodeViewRenderer } from 'svelte-tiptap';
|
||||
import { Editor, Node, mergeAttributes, type CommandProps, type NodeViewProps } from '@tiptap/core'
|
||||
import type { Component } from 'svelte'
|
||||
import { SvelteNodeViewRenderer } from 'svelte-tiptap'
|
||||
|
||||
export interface AudioPlaceholderOptions {
|
||||
HTMLAttributes: Record<string, object>;
|
||||
onDrop: (files: File[], editor: Editor) => void;
|
||||
onDropRejected?: (files: File[], editor: Editor) => void;
|
||||
onEmbed: (url: string, editor: Editor) => void;
|
||||
allowedMimeTypes?: Record<string, string[]>;
|
||||
maxFiles?: number;
|
||||
maxSize?: number;
|
||||
HTMLAttributes: Record<string, object>
|
||||
onDrop: (files: File[], editor: Editor) => void
|
||||
onDropRejected?: (files: File[], editor: Editor) => void
|
||||
onEmbed: (url: string, editor: Editor) => void
|
||||
allowedMimeTypes?: Record<string, string[]>
|
||||
maxFiles?: number
|
||||
maxSize?: number
|
||||
}
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
|
|
@ -18,8 +18,8 @@ declare module '@tiptap/core' {
|
|||
/**
|
||||
* Inserts an audio placeholder
|
||||
*/
|
||||
insertAudioPlaceholder: () => ReturnType;
|
||||
};
|
||||
insertAudioPlaceholder: () => ReturnType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -34,14 +34,14 @@ export const AudioPlaceholder = (
|
|||
onDrop: () => {},
|
||||
onDropRejected: () => {},
|
||||
onEmbed: () => {}
|
||||
};
|
||||
}
|
||||
},
|
||||
parseHTML() {
|
||||
return [{ tag: `div[data-type="${this.name}"]` }];
|
||||
return [{ tag: `div[data-type="${this.name}"]` }]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ['div', mergeAttributes(HTMLAttributes)];
|
||||
return ['div', mergeAttributes(HTMLAttributes)]
|
||||
},
|
||||
group: 'block',
|
||||
draggable: true,
|
||||
|
|
@ -50,15 +50,15 @@ export const AudioPlaceholder = (
|
|||
isolating: true,
|
||||
|
||||
addNodeView() {
|
||||
return SvelteNodeViewRenderer(component);
|
||||
return SvelteNodeViewRenderer(component)
|
||||
},
|
||||
addCommands() {
|
||||
return {
|
||||
insertAudioPlaceholder: () => (props: CommandProps) => {
|
||||
return props.commands.insertContent({
|
||||
type: 'audio-placeholder'
|
||||
});
|
||||
})
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,27 +1,27 @@
|
|||
import { Slice } from '@tiptap/pm/model';
|
||||
import { EditorView } from '@tiptap/pm/view';
|
||||
import * as pmView from '@tiptap/pm/view';
|
||||
import { Slice } from '@tiptap/pm/model'
|
||||
import { EditorView } from '@tiptap/pm/view'
|
||||
import * as pmView from '@tiptap/pm/view'
|
||||
|
||||
function getPmView() {
|
||||
try {
|
||||
return pmView;
|
||||
return pmView
|
||||
} catch (error: Error) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function serializeForClipboard(view: EditorView, slice: Slice) {
|
||||
// Newer Tiptap/ProseMirror
|
||||
if (view && typeof view.serializeForClipboard === 'function') {
|
||||
return view.serializeForClipboard(slice);
|
||||
return view.serializeForClipboard(slice)
|
||||
}
|
||||
|
||||
// Older version fallback
|
||||
const proseMirrorView = getPmView();
|
||||
const proseMirrorView = getPmView()
|
||||
|
||||
if (proseMirrorView && typeof proseMirrorView?.__serializeForClipboard === 'function') {
|
||||
return proseMirrorView.__serializeForClipboard(view, slice);
|
||||
return proseMirrorView.__serializeForClipboard(view, slice)
|
||||
}
|
||||
|
||||
throw new Error('No supported clipboard serialization method found.');
|
||||
throw new Error('No supported clipboard serialization method found.')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,61 +1,61 @@
|
|||
import { Extension } from '@tiptap/core';
|
||||
import { NodeSelection, Plugin, PluginKey, TextSelection } from '@tiptap/pm/state';
|
||||
import { Fragment, Slice, Node } from '@tiptap/pm/model';
|
||||
import { EditorView } from '@tiptap/pm/view';
|
||||
import { serializeForClipboard } from './ClipboardSerializer.js';
|
||||
import { Extension } from '@tiptap/core'
|
||||
import { NodeSelection, Plugin, PluginKey, TextSelection } from '@tiptap/pm/state'
|
||||
import { Fragment, Slice, Node } from '@tiptap/pm/model'
|
||||
import { EditorView } from '@tiptap/pm/view'
|
||||
import { serializeForClipboard } from './ClipboardSerializer.js'
|
||||
|
||||
export interface GlobalDragHandleOptions {
|
||||
/**
|
||||
* The width of the drag handle
|
||||
*/
|
||||
dragHandleWidth: number;
|
||||
dragHandleWidth: number
|
||||
|
||||
/**
|
||||
* The treshold for scrolling
|
||||
*/
|
||||
scrollTreshold: number;
|
||||
scrollTreshold: number
|
||||
|
||||
/*
|
||||
* The css selector to query for the drag handle. (eg: '.custom-handle').
|
||||
* If handle element is found, that element will be used as drag handle. If not, a default handle will be created
|
||||
*/
|
||||
dragHandleSelector?: string;
|
||||
dragHandleSelector?: string
|
||||
|
||||
/**
|
||||
* Tags to be excluded for drag handle
|
||||
*/
|
||||
excludedTags: string[];
|
||||
excludedTags: string[]
|
||||
|
||||
/**
|
||||
* Custom nodes to be included for drag handle
|
||||
*/
|
||||
customNodes: string[];
|
||||
customNodes: string[]
|
||||
|
||||
/**
|
||||
* onNodeChange callback for drag handle
|
||||
* @param data
|
||||
* @returns
|
||||
*/
|
||||
onMouseMove?: (data: { node: Node; pos: number }) => void;
|
||||
onMouseMove?: (data: { node: Node; pos: number }) => void
|
||||
}
|
||||
function absoluteRect(node: Element) {
|
||||
const data = node.getBoundingClientRect();
|
||||
const modal = node.closest('[role="dialog"]');
|
||||
const data = node.getBoundingClientRect()
|
||||
const modal = node.closest('[role="dialog"]')
|
||||
|
||||
if (modal && window.getComputedStyle(modal).transform !== 'none') {
|
||||
const modalRect = modal.getBoundingClientRect();
|
||||
const modalRect = modal.getBoundingClientRect()
|
||||
|
||||
return {
|
||||
top: data.top - modalRect.top,
|
||||
left: data.left - modalRect.left,
|
||||
width: data.width
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
top: data.top,
|
||||
left: data.left,
|
||||
width: data.width
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function nodeDOMAtCoords(coords: { x: number; y: number }, options: GlobalDragHandleOptions) {
|
||||
|
|
@ -71,34 +71,34 @@ function nodeDOMAtCoords(coords: { x: number; y: number }, options: GlobalDragHa
|
|||
'h5',
|
||||
'h6',
|
||||
...options.customNodes.map((node) => `[data-type=${node}]`)
|
||||
].join(', ');
|
||||
].join(', ')
|
||||
return document
|
||||
.elementsFromPoint(coords.x, coords.y)
|
||||
.find(
|
||||
(elem: Element) => elem.parentElement?.matches?.('.ProseMirror') || elem.matches(selectors)
|
||||
);
|
||||
)
|
||||
}
|
||||
function nodePosAtDOM(node: Element, view: EditorView, options: GlobalDragHandleOptions) {
|
||||
const boundingRect = node.getBoundingClientRect();
|
||||
const boundingRect = node.getBoundingClientRect()
|
||||
|
||||
return view.posAtCoords({
|
||||
left: boundingRect.left + 50 + options.dragHandleWidth,
|
||||
top: boundingRect.top + 1
|
||||
})?.inside;
|
||||
})?.inside
|
||||
}
|
||||
|
||||
function calcNodePos(pos: number, view: EditorView) {
|
||||
const $pos = view.state.doc.resolve(pos);
|
||||
if ($pos.depth > 1) return $pos.before($pos.depth);
|
||||
return pos;
|
||||
const $pos = view.state.doc.resolve(pos)
|
||||
if ($pos.depth > 1) return $pos.before($pos.depth)
|
||||
return pos
|
||||
}
|
||||
|
||||
export function DragHandlePlugin(options: GlobalDragHandleOptions & { pluginKey: string }) {
|
||||
let listType = '';
|
||||
let listType = ''
|
||||
function handleDragStart(event: DragEvent, view: EditorView) {
|
||||
view.focus();
|
||||
view.focus()
|
||||
|
||||
if (!event.dataTransfer) return;
|
||||
if (!event.dataTransfer) return
|
||||
|
||||
const node = nodeDOMAtCoords(
|
||||
{
|
||||
|
|
@ -106,38 +106,38 @@ export function DragHandlePlugin(options: GlobalDragHandleOptions & { pluginKey:
|
|||
y: event.clientY
|
||||
},
|
||||
options
|
||||
);
|
||||
)
|
||||
|
||||
if (!(node instanceof Element)) return;
|
||||
if (!(node instanceof Element)) return
|
||||
|
||||
let draggedNodePos = nodePosAtDOM(node, view, options);
|
||||
if (draggedNodePos == null || draggedNodePos < 0) return;
|
||||
draggedNodePos = calcNodePos(draggedNodePos, view);
|
||||
let draggedNodePos = nodePosAtDOM(node, view, options)
|
||||
if (draggedNodePos == null || draggedNodePos < 0) return
|
||||
draggedNodePos = calcNodePos(draggedNodePos, view)
|
||||
|
||||
const { from, to } = view.state.selection;
|
||||
const diff = from - to;
|
||||
const { from, to } = view.state.selection
|
||||
const diff = from - to
|
||||
|
||||
const fromSelectionPos = calcNodePos(from, view);
|
||||
let differentNodeSelected = false;
|
||||
const fromSelectionPos = calcNodePos(from, view)
|
||||
let differentNodeSelected = false
|
||||
|
||||
const nodePos = view.state.doc.resolve(fromSelectionPos);
|
||||
const nodePos = view.state.doc.resolve(fromSelectionPos)
|
||||
|
||||
// Check if nodePos points to the top level node
|
||||
if (nodePos.node().type.name === 'doc') differentNodeSelected = true;
|
||||
if (nodePos.node().type.name === 'doc') differentNodeSelected = true
|
||||
else {
|
||||
const nodeSelection = NodeSelection.create(view.state.doc, nodePos.before());
|
||||
const nodeSelection = NodeSelection.create(view.state.doc, nodePos.before())
|
||||
|
||||
// Check if the node where the drag event started is part of the current selection
|
||||
differentNodeSelected = !(
|
||||
draggedNodePos + 1 >= nodeSelection.$from.pos && draggedNodePos <= nodeSelection.$to.pos
|
||||
);
|
||||
)
|
||||
}
|
||||
let selection = view.state.selection;
|
||||
let selection = view.state.selection
|
||||
if (!differentNodeSelected && diff !== 0 && !(view.state.selection instanceof NodeSelection)) {
|
||||
const endSelection = NodeSelection.create(view.state.doc, to - 1);
|
||||
selection = TextSelection.create(view.state.doc, draggedNodePos, endSelection.$to.pos);
|
||||
const endSelection = NodeSelection.create(view.state.doc, to - 1)
|
||||
selection = TextSelection.create(view.state.doc, draggedNodePos, endSelection.$to.pos)
|
||||
} else {
|
||||
selection = NodeSelection.create(view.state.doc, draggedNodePos);
|
||||
selection = NodeSelection.create(view.state.doc, draggedNodePos)
|
||||
|
||||
// if inline node is selected, e.g mention -> go to the parent node to select the whole node
|
||||
// if table row is selected, go to the parent node to select the whole node
|
||||
|
|
@ -145,58 +145,58 @@ export function DragHandlePlugin(options: GlobalDragHandleOptions & { pluginKey:
|
|||
(selection as NodeSelection).node.type.isInline ||
|
||||
(selection as NodeSelection).node.type.name === 'tableRow'
|
||||
) {
|
||||
const $pos = view.state.doc.resolve(selection.from);
|
||||
selection = NodeSelection.create(view.state.doc, $pos.before());
|
||||
const $pos = view.state.doc.resolve(selection.from)
|
||||
selection = NodeSelection.create(view.state.doc, $pos.before())
|
||||
}
|
||||
}
|
||||
view.dispatch(view.state.tr.setSelection(selection));
|
||||
view.dispatch(view.state.tr.setSelection(selection))
|
||||
|
||||
// If the selected node is a list item, we need to save the type of the wrapping list e.g. OL or UL
|
||||
if (
|
||||
view.state.selection instanceof NodeSelection &&
|
||||
view.state.selection.node.type.name === 'listItem'
|
||||
) {
|
||||
listType = node.parentElement!.tagName;
|
||||
listType = node.parentElement!.tagName
|
||||
}
|
||||
|
||||
const slice = view.state.selection.content();
|
||||
const { dom, text } = serializeForClipboard(view, slice);
|
||||
const slice = view.state.selection.content()
|
||||
const { dom, text } = serializeForClipboard(view, slice)
|
||||
|
||||
event.dataTransfer.clearData();
|
||||
event.dataTransfer.setData('text/html', dom.innerHTML);
|
||||
event.dataTransfer.setData('text/plain', text);
|
||||
event.dataTransfer.effectAllowed = 'copyMove';
|
||||
event.dataTransfer.clearData()
|
||||
event.dataTransfer.setData('text/html', dom.innerHTML)
|
||||
event.dataTransfer.setData('text/plain', text)
|
||||
event.dataTransfer.effectAllowed = 'copyMove'
|
||||
|
||||
event.dataTransfer.setDragImage(node, 0, 0);
|
||||
event.dataTransfer.setDragImage(node, 0, 0)
|
||||
|
||||
view.dragging = { slice, move: event.ctrlKey };
|
||||
view.dragging = { slice, move: event.ctrlKey }
|
||||
}
|
||||
|
||||
let dragHandleElement: HTMLElement | null = null;
|
||||
let dragHandleElement: HTMLElement | null = null
|
||||
|
||||
function hideDragHandle() {
|
||||
if (dragHandleElement) {
|
||||
dragHandleElement.classList.add('hide');
|
||||
dragHandleElement.classList.add('hide')
|
||||
}
|
||||
}
|
||||
|
||||
function showDragHandle() {
|
||||
if (dragHandleElement) {
|
||||
dragHandleElement.classList.remove('hide');
|
||||
dragHandleElement.classList.remove('hide')
|
||||
}
|
||||
}
|
||||
|
||||
function hideHandleOnEditorOut(event: MouseEvent) {
|
||||
if (event.target instanceof Element) {
|
||||
// Check if the relatedTarget class is still inside the editor
|
||||
const relatedTarget = event.relatedTarget as HTMLElement;
|
||||
const relatedTarget = event.relatedTarget as HTMLElement
|
||||
const isInsideEditor =
|
||||
relatedTarget?.classList.contains('tiptap') ||
|
||||
relatedTarget?.classList.contains('drag-handle');
|
||||
relatedTarget?.classList.contains('drag-handle')
|
||||
|
||||
if (isInsideEditor) return;
|
||||
if (isInsideEditor) return
|
||||
}
|
||||
hideDragHandle();
|
||||
hideDragHandle()
|
||||
}
|
||||
|
||||
return new Plugin({
|
||||
|
|
@ -204,54 +204,54 @@ export function DragHandlePlugin(options: GlobalDragHandleOptions & { pluginKey:
|
|||
view: (view) => {
|
||||
const handleBySelector = options.dragHandleSelector
|
||||
? document.querySelector<HTMLElement>(options.dragHandleSelector)
|
||||
: null;
|
||||
dragHandleElement = handleBySelector ?? document.createElement('div');
|
||||
dragHandleElement.draggable = true;
|
||||
dragHandleElement.dataset.dragHandle = '';
|
||||
dragHandleElement.classList.add('drag-handle');
|
||||
: null
|
||||
dragHandleElement = handleBySelector ?? document.createElement('div')
|
||||
dragHandleElement.draggable = true
|
||||
dragHandleElement.dataset.dragHandle = ''
|
||||
dragHandleElement.classList.add('drag-handle')
|
||||
|
||||
function onDragHandleDragStart(e: DragEvent) {
|
||||
handleDragStart(e, view);
|
||||
handleDragStart(e, view)
|
||||
}
|
||||
|
||||
dragHandleElement.addEventListener('dragstart', onDragHandleDragStart);
|
||||
dragHandleElement.addEventListener('dragstart', onDragHandleDragStart)
|
||||
|
||||
function onDragHandleDrag(e: DragEvent) {
|
||||
hideDragHandle();
|
||||
const scrollY = window.scrollY;
|
||||
hideDragHandle()
|
||||
const scrollY = window.scrollY
|
||||
if (e.clientY < options.scrollTreshold) {
|
||||
window.scrollTo({ top: scrollY - 30, behavior: 'smooth' });
|
||||
window.scrollTo({ top: scrollY - 30, behavior: 'smooth' })
|
||||
} else if (window.innerHeight - e.clientY < options.scrollTreshold) {
|
||||
window.scrollTo({ top: scrollY + 30, behavior: 'smooth' });
|
||||
window.scrollTo({ top: scrollY + 30, behavior: 'smooth' })
|
||||
}
|
||||
}
|
||||
|
||||
dragHandleElement.addEventListener('drag', onDragHandleDrag);
|
||||
dragHandleElement.addEventListener('drag', onDragHandleDrag)
|
||||
|
||||
hideDragHandle();
|
||||
hideDragHandle()
|
||||
|
||||
if (!handleBySelector) {
|
||||
view?.dom?.parentElement?.appendChild(dragHandleElement);
|
||||
view?.dom?.parentElement?.appendChild(dragHandleElement)
|
||||
}
|
||||
view?.dom?.parentElement?.addEventListener('mouseout', hideHandleOnEditorOut);
|
||||
view?.dom?.parentElement?.addEventListener('mouseout', hideHandleOnEditorOut)
|
||||
|
||||
return {
|
||||
destroy: () => {
|
||||
if (!handleBySelector) {
|
||||
dragHandleElement?.remove?.();
|
||||
dragHandleElement?.remove?.()
|
||||
}
|
||||
dragHandleElement?.removeEventListener('drag', onDragHandleDrag);
|
||||
dragHandleElement?.removeEventListener('dragstart', onDragHandleDragStart);
|
||||
dragHandleElement = null;
|
||||
view?.dom?.parentElement?.removeEventListener('mouseout', hideHandleOnEditorOut);
|
||||
dragHandleElement?.removeEventListener('drag', onDragHandleDrag)
|
||||
dragHandleElement?.removeEventListener('dragstart', onDragHandleDragStart)
|
||||
dragHandleElement = null
|
||||
view?.dom?.parentElement?.removeEventListener('mouseout', hideHandleOnEditorOut)
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
props: {
|
||||
handleDOMEvents: {
|
||||
mousemove: (view, event) => {
|
||||
if (!view.editable) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
const node = nodeDOMAtCoords(
|
||||
|
|
@ -260,76 +260,76 @@ export function DragHandlePlugin(options: GlobalDragHandleOptions & { pluginKey:
|
|||
y: event.clientY
|
||||
},
|
||||
options
|
||||
);
|
||||
)
|
||||
|
||||
const notDragging = node?.closest('.not-draggable');
|
||||
const excludedTagList = options.excludedTags.concat(['ol', 'ul']).join(', ');
|
||||
const notDragging = node?.closest('.not-draggable')
|
||||
const excludedTagList = options.excludedTags.concat(['ol', 'ul']).join(', ')
|
||||
|
||||
if (!(node instanceof Element) || node.matches(excludedTagList) || notDragging) {
|
||||
hideDragHandle();
|
||||
return;
|
||||
hideDragHandle()
|
||||
return
|
||||
}
|
||||
|
||||
const nodePos = nodePosAtDOM(node, view, options);
|
||||
const nodePos = nodePosAtDOM(node, view, options)
|
||||
if (nodePos !== undefined) {
|
||||
const currentNode = view.state.doc.nodeAt(nodePos);
|
||||
const currentNode = view.state.doc.nodeAt(nodePos)
|
||||
if (currentNode !== null) {
|
||||
options.onMouseMove?.({ node: currentNode, pos: nodePos });
|
||||
options.onMouseMove?.({ node: currentNode, pos: nodePos })
|
||||
}
|
||||
}
|
||||
|
||||
const compStyle = window.getComputedStyle(node);
|
||||
const parsedLineHeight = parseInt(compStyle.lineHeight, 10);
|
||||
const compStyle = window.getComputedStyle(node)
|
||||
const parsedLineHeight = parseInt(compStyle.lineHeight, 10)
|
||||
const lineHeight = isNaN(parsedLineHeight)
|
||||
? parseInt(compStyle.fontSize) * 1.2
|
||||
: parsedLineHeight;
|
||||
const paddingTop = parseInt(compStyle.paddingTop, 10);
|
||||
: parsedLineHeight
|
||||
const paddingTop = parseInt(compStyle.paddingTop, 10)
|
||||
|
||||
const rect = absoluteRect(node);
|
||||
const rect = absoluteRect(node)
|
||||
|
||||
rect.top += (lineHeight - 24) / 2;
|
||||
rect.top += paddingTop;
|
||||
rect.top += (lineHeight - 24) / 2
|
||||
rect.top += paddingTop
|
||||
// Li markers
|
||||
if (node.matches('ul:not([data-type=taskList]) li, ol li')) {
|
||||
rect.left -= options.dragHandleWidth;
|
||||
rect.left -= options.dragHandleWidth
|
||||
}
|
||||
rect.width = options.dragHandleWidth;
|
||||
rect.width = options.dragHandleWidth
|
||||
|
||||
if (!dragHandleElement) return;
|
||||
if (!dragHandleElement) return
|
||||
|
||||
dragHandleElement.style.left = `${rect.left - rect.width}px`;
|
||||
dragHandleElement.style.top = `${rect.top}px`;
|
||||
showDragHandle();
|
||||
dragHandleElement.style.left = `${rect.left - rect.width}px`
|
||||
dragHandleElement.style.top = `${rect.top}px`
|
||||
showDragHandle()
|
||||
},
|
||||
keydown: () => {
|
||||
hideDragHandle();
|
||||
hideDragHandle()
|
||||
},
|
||||
mousewheel: () => {
|
||||
hideDragHandle();
|
||||
hideDragHandle()
|
||||
},
|
||||
// dragging class is used for CSS
|
||||
dragstart: (view) => {
|
||||
view.dom.classList.add('dragging');
|
||||
view.dom.classList.add('dragging')
|
||||
},
|
||||
drop: (view, event) => {
|
||||
view.dom.classList.remove('dragging');
|
||||
hideDragHandle();
|
||||
let droppedNode: Node | null = null;
|
||||
view.dom.classList.remove('dragging')
|
||||
hideDragHandle()
|
||||
let droppedNode: Node | null = null
|
||||
const dropPos = view.posAtCoords({
|
||||
left: event.clientX,
|
||||
top: event.clientY
|
||||
});
|
||||
})
|
||||
|
||||
if (!dropPos) return;
|
||||
if (!dropPos) return
|
||||
|
||||
if (view.state.selection instanceof NodeSelection) {
|
||||
droppedNode = view.state.selection.node;
|
||||
droppedNode = view.state.selection.node
|
||||
}
|
||||
if (!droppedNode) return;
|
||||
if (!droppedNode) return
|
||||
|
||||
const resolvedPos = view.state.doc.resolve(dropPos.pos);
|
||||
const resolvedPos = view.state.doc.resolve(dropPos.pos)
|
||||
|
||||
const isDroppedInsideList = resolvedPos.parent.type.name === 'listItem';
|
||||
const isDroppedInsideList = resolvedPos.parent.type.name === 'listItem'
|
||||
|
||||
// If the selected node is a list item and is not dropped inside a list, we need to wrap it inside <ol> tag otherwise ol list items will be transformed into ul list item when dropped
|
||||
if (
|
||||
|
|
@ -338,17 +338,17 @@ export function DragHandlePlugin(options: GlobalDragHandleOptions & { pluginKey:
|
|||
!isDroppedInsideList &&
|
||||
listType == 'OL'
|
||||
) {
|
||||
const newList = view.state.schema.nodes.orderedList?.createAndFill(null, droppedNode);
|
||||
const slice = new Slice(Fragment.from(newList), 0, 0);
|
||||
view.dragging = { slice, move: event.ctrlKey };
|
||||
const newList = view.state.schema.nodes.orderedList?.createAndFill(null, droppedNode)
|
||||
const slice = new Slice(Fragment.from(newList), 0, 0)
|
||||
view.dragging = { slice, move: event.ctrlKey }
|
||||
}
|
||||
},
|
||||
dragend: (view) => {
|
||||
view.dom.classList.remove('dragging');
|
||||
view.dom.classList.remove('dragging')
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
const GlobalDragHandle = Extension.create({
|
||||
|
|
@ -360,7 +360,7 @@ const GlobalDragHandle = Extension.create({
|
|||
scrollTreshold: 100,
|
||||
excludedTags: [],
|
||||
customNodes: []
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
|
|
@ -374,8 +374,8 @@ const GlobalDragHandle = Extension.create({
|
|||
customNodes: this.options.customNodes,
|
||||
onMouseMove: this.options.onMouseMove
|
||||
})
|
||||
];
|
||||
]
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
export default GlobalDragHandle;
|
||||
export default GlobalDragHandle
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { Node } from '@tiptap/core';
|
||||
import { Node } from '@tiptap/core'
|
||||
|
||||
export interface IframeOptions {
|
||||
allowFullscreen: boolean;
|
||||
allowFullscreen: boolean
|
||||
HTMLAttributes: {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
|
|
@ -13,9 +13,9 @@ declare module '@tiptap/core' {
|
|||
/**
|
||||
* Add an iframe with src
|
||||
*/
|
||||
setIframe: (options: { src: string }) => ReturnType;
|
||||
removeIframe: () => ReturnType;
|
||||
};
|
||||
setIframe: (options: { src: string }) => ReturnType
|
||||
removeIframe: () => ReturnType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -32,7 +32,7 @@ export default Node.create<IframeOptions>({
|
|||
HTMLAttributes: {
|
||||
class: 'iframe-wrapper'
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
|
|
@ -47,7 +47,7 @@ export default Node.create<IframeOptions>({
|
|||
default: this.options.allowFullscreen,
|
||||
parseHTML: () => this.options.allowFullscreen
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
|
|
@ -55,11 +55,11 @@ export default Node.create<IframeOptions>({
|
|||
{
|
||||
tag: 'iframe'
|
||||
}
|
||||
];
|
||||
]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ['div', this.options.HTMLAttributes, ['iframe', HTMLAttributes]];
|
||||
return ['div', this.options.HTMLAttributes, ['iframe', HTMLAttributes]]
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
|
|
@ -67,19 +67,19 @@ export default Node.create<IframeOptions>({
|
|||
setIframe:
|
||||
(options: { src: string }) =>
|
||||
({ tr, dispatch }) => {
|
||||
const { selection } = tr;
|
||||
const node = this.type.create(options);
|
||||
const { selection } = tr
|
||||
const node = this.type.create(options)
|
||||
|
||||
if (dispatch) {
|
||||
tr.replaceRangeWith(selection.from, selection.to, node);
|
||||
tr.replaceRangeWith(selection.from, selection.to, node)
|
||||
}
|
||||
|
||||
return true;
|
||||
return true
|
||||
},
|
||||
removeIframe:
|
||||
() =>
|
||||
({ commands }) =>
|
||||
commands.deleteNode(this.name)
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { SvelteNodeViewRenderer } from 'svelte-tiptap';
|
||||
import { SvelteNodeViewRenderer } from 'svelte-tiptap'
|
||||
|
||||
import type { NodeViewProps } from '@tiptap/core';
|
||||
import type { Component } from 'svelte';
|
||||
import IFrame from './IFrame.js';
|
||||
import type { NodeViewProps } from '@tiptap/core'
|
||||
import type { Component } from 'svelte'
|
||||
import IFrame from './IFrame.js'
|
||||
|
||||
export const IFrameExtended = (content: Component<NodeViewProps>) =>
|
||||
IFrame.extend({
|
||||
|
|
@ -26,10 +26,10 @@ export const IFrameExtended = (content: Component<NodeViewProps>) =>
|
|||
align: {
|
||||
default: 'left'
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
addNodeView: () => {
|
||||
return SvelteNodeViewRenderer(content);
|
||||
return SvelteNodeViewRenderer(content)
|
||||
}
|
||||
});
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { Node, mergeAttributes, type CommandProps, type NodeViewProps } from '@tiptap/core';
|
||||
import type { Component } from 'svelte';
|
||||
import { SvelteNodeViewRenderer } from 'svelte-tiptap';
|
||||
import { Node, mergeAttributes, type CommandProps, type NodeViewProps } from '@tiptap/core'
|
||||
import type { Component } from 'svelte'
|
||||
import { SvelteNodeViewRenderer } from 'svelte-tiptap'
|
||||
|
||||
export interface IFramePlaceholderOptions {
|
||||
HTMLAttributes: Record<string, object>;
|
||||
HTMLAttributes: Record<string, object>
|
||||
}
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
|
|
@ -12,8 +12,8 @@ declare module '@tiptap/core' {
|
|||
/**
|
||||
* Inserts a IFrame placeholder
|
||||
*/
|
||||
insertIFramePlaceholder: () => ReturnType;
|
||||
};
|
||||
insertIFramePlaceholder: () => ReturnType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -26,14 +26,14 @@ export const IFramePlaceholder = (content: Component<NodeViewProps>) =>
|
|||
onDrop: () => {},
|
||||
onDropRejected: () => {},
|
||||
onEmbed: () => {}
|
||||
};
|
||||
}
|
||||
},
|
||||
parseHTML() {
|
||||
return [{ tag: `div[data-type="${this.name}"]` }];
|
||||
return [{ tag: `div[data-type="${this.name}"]` }]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ['div', mergeAttributes(HTMLAttributes)];
|
||||
return ['div', mergeAttributes(HTMLAttributes)]
|
||||
},
|
||||
group: 'block',
|
||||
draggable: true,
|
||||
|
|
@ -42,15 +42,15 @@ export const IFramePlaceholder = (content: Component<NodeViewProps>) =>
|
|||
isolating: true,
|
||||
|
||||
addNodeView() {
|
||||
return SvelteNodeViewRenderer(content);
|
||||
return SvelteNodeViewRenderer(content)
|
||||
},
|
||||
addCommands() {
|
||||
return {
|
||||
insertIFramePlaceholder: () => (props: CommandProps) => {
|
||||
return props.commands.insertContent({
|
||||
type: 'iframe-placeholder'
|
||||
});
|
||||
})
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { SvelteNodeViewRenderer } from 'svelte-tiptap';
|
||||
import Image, { type ImageOptions } from '@tiptap/extension-image';
|
||||
import type { Component } from 'svelte';
|
||||
import type { NodeViewProps, Node } from '@tiptap/core';
|
||||
import { SvelteNodeViewRenderer } from 'svelte-tiptap'
|
||||
import Image, { type ImageOptions } from '@tiptap/extension-image'
|
||||
import type { Component } from 'svelte'
|
||||
import type { NodeViewProps, Node } from '@tiptap/core'
|
||||
|
||||
export const ImageExtended = (component: Component<NodeViewProps>): Node<ImageOptions, unknown> => {
|
||||
return Image.extend({
|
||||
|
|
@ -25,12 +25,12 @@ export const ImageExtended = (component: Component<NodeViewProps>): Node<ImageOp
|
|||
align: {
|
||||
default: 'left'
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
addNodeView: () => {
|
||||
return SvelteNodeViewRenderer(component);
|
||||
return SvelteNodeViewRenderer(component)
|
||||
}
|
||||
}).configure({
|
||||
allowBase64: true
|
||||
});
|
||||
};
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
import { Editor, Node, mergeAttributes, type CommandProps, type NodeViewProps } from '@tiptap/core';
|
||||
import type { Component } from 'svelte';
|
||||
import { SvelteNodeViewRenderer } from 'svelte-tiptap';
|
||||
import { Editor, Node, mergeAttributes, type CommandProps, type NodeViewProps } from '@tiptap/core'
|
||||
import type { Component } from 'svelte'
|
||||
import { SvelteNodeViewRenderer } from 'svelte-tiptap'
|
||||
|
||||
export interface ImagePlaceholderOptions {
|
||||
HTMLAttributes: Record<string, object>;
|
||||
onDrop: (files: File[], editor: Editor) => void;
|
||||
onDropRejected?: (files: File[], editor: Editor) => void;
|
||||
onEmbed: (url: string, editor: Editor) => void;
|
||||
allowedMimeTypes?: Record<string, string[]>;
|
||||
maxFiles?: number;
|
||||
maxSize?: number;
|
||||
HTMLAttributes: Record<string, object>
|
||||
onDrop: (files: File[], editor: Editor) => void
|
||||
onDropRejected?: (files: File[], editor: Editor) => void
|
||||
onEmbed: (url: string, editor: Editor) => void
|
||||
allowedMimeTypes?: Record<string, string[]>
|
||||
maxFiles?: number
|
||||
maxSize?: number
|
||||
}
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
|
|
@ -18,8 +18,8 @@ declare module '@tiptap/core' {
|
|||
/**
|
||||
* Inserts an image placeholder
|
||||
*/
|
||||
insertImagePlaceholder: () => ReturnType;
|
||||
};
|
||||
insertImagePlaceholder: () => ReturnType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -34,14 +34,14 @@ export const ImagePlaceholder = (
|
|||
onDrop: () => {},
|
||||
onDropRejected: () => {},
|
||||
onEmbed: () => {}
|
||||
};
|
||||
}
|
||||
},
|
||||
parseHTML() {
|
||||
return [{ tag: `div[data-type="${this.name}"]` }];
|
||||
return [{ tag: `div[data-type="${this.name}"]` }]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ['div', mergeAttributes(HTMLAttributes)];
|
||||
return ['div', mergeAttributes(HTMLAttributes)]
|
||||
},
|
||||
group: 'block',
|
||||
draggable: true,
|
||||
|
|
@ -50,15 +50,15 @@ export const ImagePlaceholder = (
|
|||
isolating: true,
|
||||
|
||||
addNodeView() {
|
||||
return SvelteNodeViewRenderer(component);
|
||||
return SvelteNodeViewRenderer(component)
|
||||
},
|
||||
addCommands() {
|
||||
return {
|
||||
insertImagePlaceholder: () => (props: CommandProps) => {
|
||||
return props.commands.insertContent({
|
||||
type: 'image-placeholder'
|
||||
});
|
||||
})
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import { commands } from '../../commands/commands.js';
|
||||
import { commands } from '../../commands/commands.js'
|
||||
|
||||
import type { EdraCommand } from '../../commands/types.js';
|
||||
import type { Editor } from '@tiptap/core';
|
||||
import type { EdraCommand } from '../../commands/types.js'
|
||||
import type { Editor } from '@tiptap/core'
|
||||
|
||||
export interface Group {
|
||||
name: string;
|
||||
title: string;
|
||||
commands: EdraCommand[];
|
||||
name: string
|
||||
title: string
|
||||
commands: EdraCommand[]
|
||||
}
|
||||
|
||||
export const GROUPS: Group[] = [
|
||||
|
|
@ -20,7 +20,7 @@ export const GROUPS: Group[] = [
|
|||
name: 'blockquote',
|
||||
label: 'Blockquote',
|
||||
action: (editor: Editor) => {
|
||||
editor.chain().focus().setBlockquote().run();
|
||||
editor.chain().focus().setBlockquote().run()
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -28,7 +28,7 @@ export const GROUPS: Group[] = [
|
|||
name: 'codeBlock',
|
||||
label: 'Code Block',
|
||||
action: (editor: Editor) => {
|
||||
editor.chain().focus().setCodeBlock().run();
|
||||
editor.chain().focus().setCodeBlock().run()
|
||||
}
|
||||
},
|
||||
...commands.lists.commands
|
||||
|
|
@ -45,11 +45,11 @@ export const GROUPS: Group[] = [
|
|||
name: 'horizontalRule',
|
||||
label: 'Horizontal Rule',
|
||||
action: (editor: Editor) => {
|
||||
editor.chain().focus().setHorizontalRule().run();
|
||||
editor.chain().focus().setHorizontalRule().run()
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
]
|
||||
|
||||
export default GROUPS;
|
||||
export default GROUPS
|
||||
|
|
|
|||
|
|
@ -1,16 +1,16 @@
|
|||
import { Editor, Extension } from '@tiptap/core';
|
||||
import Suggestion, { type SuggestionProps, type SuggestionKeyDownProps } from '@tiptap/suggestion';
|
||||
import { PluginKey } from '@tiptap/pm/state';
|
||||
import { Editor, Extension } from '@tiptap/core'
|
||||
import Suggestion, { type SuggestionProps, type SuggestionKeyDownProps } from '@tiptap/suggestion'
|
||||
import { PluginKey } from '@tiptap/pm/state'
|
||||
|
||||
import { GROUPS } from './groups.js';
|
||||
import SvelteRenderer from '../../svelte-renderer.js';
|
||||
import tippy from 'tippy.js';
|
||||
import type { Component } from 'svelte';
|
||||
import { GROUPS } from './groups.js'
|
||||
import SvelteRenderer from '../../svelte-renderer.js'
|
||||
import tippy from 'tippy.js'
|
||||
import type { Component } from 'svelte'
|
||||
|
||||
const extensionName = 'slashCommand';
|
||||
const extensionName = 'slashCommand'
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let popup: any;
|
||||
let popup: any
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export default (menuList: Component<any, any, ''>): Extension =>
|
||||
|
|
@ -36,7 +36,7 @@ export default (menuList: Component<any, any, ''>): Extension =>
|
|||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
})
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
|
|
@ -47,53 +47,53 @@ export default (menuList: Component<any, any, ''>): Extension =>
|
|||
allowSpaces: true,
|
||||
pluginKey: new PluginKey(extensionName),
|
||||
allow: ({ state, range }) => {
|
||||
const $from = state.doc.resolve(range.from);
|
||||
const $from = state.doc.resolve(range.from)
|
||||
const afterContent = $from.parent.textContent?.substring(
|
||||
$from.parent.textContent?.indexOf('/')
|
||||
);
|
||||
const isValidAfterContent = !afterContent?.endsWith(' ');
|
||||
)
|
||||
const isValidAfterContent = !afterContent?.endsWith(' ')
|
||||
|
||||
return isValidAfterContent;
|
||||
return isValidAfterContent
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
command: ({ editor, props }: { editor: Editor; props: any }) => {
|
||||
const { view, state } = editor;
|
||||
const { $head, $from } = view.state.selection;
|
||||
const { view, state } = editor
|
||||
const { $head, $from } = view.state.selection
|
||||
|
||||
try {
|
||||
const end = $from.pos;
|
||||
const end = $from.pos
|
||||
const from = $head?.nodeBefore
|
||||
? end -
|
||||
($head.nodeBefore.text?.substring($head.nodeBefore.text?.indexOf('/')).length ??
|
||||
0)
|
||||
: $from.start();
|
||||
: $from.start()
|
||||
|
||||
const tr = state.tr.deleteRange(from, end);
|
||||
view.dispatch(tr);
|
||||
const tr = state.tr.deleteRange(from, end)
|
||||
view.dispatch(tr)
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
console.error(error)
|
||||
}
|
||||
|
||||
props.action(editor);
|
||||
view.focus();
|
||||
props.action(editor)
|
||||
view.focus()
|
||||
},
|
||||
items: ({ query }: { query: string }) => {
|
||||
const withFilteredCommands = GROUPS.map((group) => ({
|
||||
...group,
|
||||
commands: group.commands.filter((item) => {
|
||||
const labelNormalized = item.label.toLowerCase().trim();
|
||||
const queryNormalized = query.toLowerCase().trim();
|
||||
return labelNormalized.includes(queryNormalized);
|
||||
const labelNormalized = item.label.toLowerCase().trim()
|
||||
const queryNormalized = query.toLowerCase().trim()
|
||||
return labelNormalized.includes(queryNormalized)
|
||||
})
|
||||
}));
|
||||
}))
|
||||
|
||||
const withoutEmptyGroups = withFilteredCommands.filter((group) => {
|
||||
if (group.commands.length > 0) {
|
||||
return true;
|
||||
return true
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
return false
|
||||
})
|
||||
|
||||
const withEnabledSettings = withoutEmptyGroups.map((group) => ({
|
||||
...group,
|
||||
|
|
@ -101,98 +101,96 @@ export default (menuList: Component<any, any, ''>): Extension =>
|
|||
...command,
|
||||
isEnabled: true
|
||||
}))
|
||||
}));
|
||||
}))
|
||||
|
||||
return withEnabledSettings;
|
||||
return withEnabledSettings
|
||||
},
|
||||
render: () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let component: any;
|
||||
let component: any
|
||||
|
||||
let scrollHandler: (() => void) | null = null;
|
||||
let scrollHandler: (() => void) | null = null
|
||||
|
||||
return {
|
||||
onStart: (props: SuggestionProps) => {
|
||||
component = new SvelteRenderer(menuList, {
|
||||
props,
|
||||
editor: props.editor
|
||||
});
|
||||
})
|
||||
|
||||
const { view } = props.editor;
|
||||
const { view } = props.editor
|
||||
|
||||
const getReferenceClientRect = () => {
|
||||
if (!props.clientRect) {
|
||||
return props.editor.storage[extensionName].rect;
|
||||
return props.editor.storage[extensionName].rect
|
||||
}
|
||||
|
||||
const rect = props.clientRect();
|
||||
const rect = props.clientRect()
|
||||
|
||||
if (!rect) {
|
||||
return props.editor.storage[extensionName].rect;
|
||||
return props.editor.storage[extensionName].rect
|
||||
}
|
||||
|
||||
let yPos = rect.y;
|
||||
let yPos = rect.y
|
||||
|
||||
if (rect.top + component.element.offsetHeight + 40 > window.innerHeight) {
|
||||
const diff =
|
||||
rect.top + component.element.offsetHeight - window.innerHeight + 40;
|
||||
yPos = rect.y - diff;
|
||||
const diff = rect.top + component.element.offsetHeight - window.innerHeight + 40
|
||||
yPos = rect.y - diff
|
||||
}
|
||||
|
||||
return new DOMRect(rect.x, yPos, rect.width, rect.height);
|
||||
};
|
||||
return new DOMRect(rect.x, yPos, rect.width, rect.height)
|
||||
}
|
||||
|
||||
scrollHandler = () => {
|
||||
popup?.[0].setProps({
|
||||
getReferenceClientRect
|
||||
});
|
||||
};
|
||||
})
|
||||
}
|
||||
|
||||
view.dom.parentElement?.addEventListener('scroll', scrollHandler);
|
||||
view.dom.parentElement?.addEventListener('scroll', scrollHandler)
|
||||
|
||||
popup?.[0].setProps({
|
||||
getReferenceClientRect,
|
||||
appendTo: () => document.body,
|
||||
content: component.element
|
||||
});
|
||||
})
|
||||
|
||||
popup?.[0].show();
|
||||
popup?.[0].show()
|
||||
},
|
||||
|
||||
onUpdate(props: SuggestionProps) {
|
||||
component.updateProps(props);
|
||||
component.updateProps(props)
|
||||
|
||||
const { view } = props.editor;
|
||||
const { view } = props.editor
|
||||
|
||||
const getReferenceClientRect = () => {
|
||||
if (!props.clientRect) {
|
||||
return props.editor.storage[extensionName].rect;
|
||||
return props.editor.storage[extensionName].rect
|
||||
}
|
||||
|
||||
const rect = props.clientRect();
|
||||
const rect = props.clientRect()
|
||||
|
||||
if (!rect) {
|
||||
return props.editor.storage[extensionName].rect;
|
||||
return props.editor.storage[extensionName].rect
|
||||
}
|
||||
|
||||
let yPos = rect.y;
|
||||
let yPos = rect.y
|
||||
|
||||
if (rect.top + component.element.offsetHeight + 40 > window.innerHeight) {
|
||||
const diff =
|
||||
rect.top + component.element.offsetHeight - window.innerHeight + 40;
|
||||
yPos = rect.y - diff;
|
||||
const diff = rect.top + component.element.offsetHeight - window.innerHeight + 40
|
||||
yPos = rect.y - diff
|
||||
}
|
||||
|
||||
return new DOMRect(rect.x, yPos, rect.width, rect.height);
|
||||
};
|
||||
return new DOMRect(rect.x, yPos, rect.width, rect.height)
|
||||
}
|
||||
|
||||
const scrollHandler = () => {
|
||||
popup?.[0].setProps({
|
||||
getReferenceClientRect
|
||||
});
|
||||
};
|
||||
})
|
||||
}
|
||||
|
||||
view.dom.parentElement?.addEventListener('scroll', scrollHandler);
|
||||
view.dom.parentElement?.addEventListener('scroll', scrollHandler)
|
||||
|
||||
props.editor.storage[extensionName].rect = props.clientRect
|
||||
? getReferenceClientRect()
|
||||
|
|
@ -203,40 +201,40 @@ export default (menuList: Component<any, any, ''>): Extension =>
|
|||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0
|
||||
};
|
||||
}
|
||||
popup?.[0].setProps({
|
||||
getReferenceClientRect
|
||||
});
|
||||
})
|
||||
},
|
||||
|
||||
onKeyDown(props: SuggestionKeyDownProps) {
|
||||
if (props.event.key === 'Escape') {
|
||||
popup?.[0].hide();
|
||||
return true;
|
||||
popup?.[0].hide()
|
||||
return true
|
||||
}
|
||||
|
||||
if (!popup?.[0].state.isShown) {
|
||||
popup?.[0].show();
|
||||
popup?.[0].show()
|
||||
}
|
||||
|
||||
if (props.event.key === 'Enter') return true;
|
||||
if (props.event.key === 'Enter') return true
|
||||
|
||||
// return component.ref?.onKeyDown(props);
|
||||
return false;
|
||||
return false
|
||||
},
|
||||
|
||||
onExit(props) {
|
||||
popup?.[0].hide();
|
||||
popup?.[0].hide()
|
||||
if (scrollHandler) {
|
||||
const { view } = props.editor;
|
||||
view.dom.parentElement?.removeEventListener('scroll', scrollHandler);
|
||||
const { view } = props.editor
|
||||
view.dom.parentElement?.removeEventListener('scroll', scrollHandler)
|
||||
}
|
||||
component.destroy();
|
||||
component.destroy()
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
})
|
||||
];
|
||||
]
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
|
|
@ -249,6 +247,6 @@ export default (menuList: Component<any, any, ''>): Extension =>
|
|||
right: 0,
|
||||
bottom: 0
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
export { Table } from './table.js';
|
||||
export { TableCell } from './table-cell.js';
|
||||
export { TableRow } from './table-row.js';
|
||||
export { TableHeader } from './table-header.js';
|
||||
export { Table } from './table.js'
|
||||
export { TableCell } from './table-cell.js'
|
||||
export { TableRow } from './table-row.js'
|
||||
export { TableHeader } from './table-header.js'
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import { mergeAttributes, Node } from '@tiptap/core';
|
||||
import { Plugin } from '@tiptap/pm/state';
|
||||
import { Decoration, DecorationSet } from '@tiptap/pm/view';
|
||||
import { mergeAttributes, Node } from '@tiptap/core'
|
||||
import { Plugin } from '@tiptap/pm/state'
|
||||
import { Decoration, DecorationSet } from '@tiptap/pm/view'
|
||||
|
||||
import { getCellsInColumn, isRowSelected, selectRow } from './utils.js';
|
||||
import { getCellsInColumn, isRowSelected, selectRow } from './utils.js'
|
||||
|
||||
export interface TableCellOptions {
|
||||
HTMLAttributes: Record<string, unknown>;
|
||||
HTMLAttributes: Record<string, unknown>
|
||||
}
|
||||
|
||||
export const TableCell = Node.create<TableCellOptions>({
|
||||
|
|
@ -19,15 +19,15 @@ export const TableCell = Node.create<TableCellOptions>({
|
|||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {}
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: 'td' }];
|
||||
return [{ tag: 'td' }]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ['td', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0];
|
||||
return ['td', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
|
|
@ -35,90 +35,90 @@ export const TableCell = Node.create<TableCellOptions>({
|
|||
colspan: {
|
||||
default: 1,
|
||||
parseHTML: (element) => {
|
||||
const colspan = element.getAttribute('colspan');
|
||||
const value = colspan ? parseInt(colspan, 10) : 1;
|
||||
const colspan = element.getAttribute('colspan')
|
||||
const value = colspan ? parseInt(colspan, 10) : 1
|
||||
|
||||
return value;
|
||||
return value
|
||||
}
|
||||
},
|
||||
rowspan: {
|
||||
default: 1,
|
||||
parseHTML: (element) => {
|
||||
const rowspan = element.getAttribute('rowspan');
|
||||
const value = rowspan ? parseInt(rowspan, 10) : 1;
|
||||
const rowspan = element.getAttribute('rowspan')
|
||||
const value = rowspan ? parseInt(rowspan, 10) : 1
|
||||
|
||||
return value;
|
||||
return value
|
||||
}
|
||||
},
|
||||
colwidth: {
|
||||
default: null,
|
||||
parseHTML: (element) => {
|
||||
const colwidth = element.getAttribute('colwidth');
|
||||
const value = colwidth ? [parseInt(colwidth, 10)] : null;
|
||||
const colwidth = element.getAttribute('colwidth')
|
||||
const value = colwidth ? [parseInt(colwidth, 10)] : null
|
||||
|
||||
return value;
|
||||
return value
|
||||
}
|
||||
},
|
||||
style: {
|
||||
default: null
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
const { isEditable } = this.editor;
|
||||
const { isEditable } = this.editor
|
||||
|
||||
return [
|
||||
new Plugin({
|
||||
props: {
|
||||
decorations: (state) => {
|
||||
if (!isEditable) {
|
||||
return DecorationSet.empty;
|
||||
return DecorationSet.empty
|
||||
}
|
||||
|
||||
const { doc, selection } = state;
|
||||
const decorations: Decoration[] = [];
|
||||
const cells = getCellsInColumn(0)(selection);
|
||||
const { doc, selection } = state
|
||||
const decorations: Decoration[] = []
|
||||
const cells = getCellsInColumn(0)(selection)
|
||||
|
||||
if (cells) {
|
||||
cells.forEach(({ pos }: { pos: number }, index: number) => {
|
||||
decorations.push(
|
||||
Decoration.widget(pos + 1, () => {
|
||||
const rowSelected = isRowSelected(index)(selection);
|
||||
let className = 'grip-row';
|
||||
const rowSelected = isRowSelected(index)(selection)
|
||||
let className = 'grip-row'
|
||||
|
||||
if (rowSelected) {
|
||||
className += ' selected';
|
||||
className += ' selected'
|
||||
}
|
||||
|
||||
if (index === 0) {
|
||||
className += ' first';
|
||||
className += ' first'
|
||||
}
|
||||
|
||||
if (index === cells.length - 1) {
|
||||
className += ' last';
|
||||
className += ' last'
|
||||
}
|
||||
|
||||
const grip = document.createElement('a');
|
||||
const grip = document.createElement('a')
|
||||
|
||||
grip.className = className;
|
||||
grip.className = className
|
||||
grip.addEventListener('mousedown', (event) => {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
event.preventDefault()
|
||||
event.stopImmediatePropagation()
|
||||
|
||||
this.editor.view.dispatch(selectRow(index)(this.editor.state.tr));
|
||||
});
|
||||
this.editor.view.dispatch(selectRow(index)(this.editor.state.tr))
|
||||
})
|
||||
|
||||
return grip;
|
||||
return grip
|
||||
})
|
||||
);
|
||||
});
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return DecorationSet.create(doc, decorations);
|
||||
return DecorationSet.create(doc, decorations)
|
||||
}
|
||||
}
|
||||
})
|
||||
];
|
||||
]
|
||||
}
|
||||
});
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import TiptapTableHeader from '@tiptap/extension-table-header';
|
||||
import { Plugin } from '@tiptap/pm/state';
|
||||
import { Decoration, DecorationSet } from '@tiptap/pm/view';
|
||||
import TiptapTableHeader from '@tiptap/extension-table-header'
|
||||
import { Plugin } from '@tiptap/pm/state'
|
||||
import { Decoration, DecorationSet } from '@tiptap/pm/view'
|
||||
|
||||
import { getCellsInRow, isColumnSelected, selectColumn } from './utils.js';
|
||||
import { getCellsInRow, isColumnSelected, selectColumn } from './utils.js'
|
||||
|
||||
export const TableHeader = TiptapTableHeader.extend({
|
||||
addAttributes() {
|
||||
|
|
@ -16,74 +16,74 @@ export const TableHeader = TiptapTableHeader.extend({
|
|||
colwidth: {
|
||||
default: null,
|
||||
parseHTML: (element) => {
|
||||
const colwidth = element.getAttribute('colwidth');
|
||||
const value = colwidth ? colwidth.split(',').map((item) => parseInt(item, 10)) : null;
|
||||
const colwidth = element.getAttribute('colwidth')
|
||||
const value = colwidth ? colwidth.split(',').map((item) => parseInt(item, 10)) : null
|
||||
|
||||
return value;
|
||||
return value
|
||||
}
|
||||
},
|
||||
style: {
|
||||
default: null
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
const { isEditable } = this.editor;
|
||||
const { isEditable } = this.editor
|
||||
|
||||
return [
|
||||
new Plugin({
|
||||
props: {
|
||||
decorations: (state) => {
|
||||
if (!isEditable) {
|
||||
return DecorationSet.empty;
|
||||
return DecorationSet.empty
|
||||
}
|
||||
|
||||
const { doc, selection } = state;
|
||||
const decorations: Decoration[] = [];
|
||||
const cells = getCellsInRow(0)(selection);
|
||||
const { doc, selection } = state
|
||||
const decorations: Decoration[] = []
|
||||
const cells = getCellsInRow(0)(selection)
|
||||
|
||||
if (cells) {
|
||||
cells.forEach(({ pos }: { pos: number }, index: number) => {
|
||||
decorations.push(
|
||||
Decoration.widget(pos + 1, () => {
|
||||
const colSelected = isColumnSelected(index)(selection);
|
||||
let className = 'grip-column';
|
||||
const colSelected = isColumnSelected(index)(selection)
|
||||
let className = 'grip-column'
|
||||
|
||||
if (colSelected) {
|
||||
className += ' selected';
|
||||
className += ' selected'
|
||||
}
|
||||
|
||||
if (index === 0) {
|
||||
className += ' first';
|
||||
className += ' first'
|
||||
}
|
||||
|
||||
if (index === cells.length - 1) {
|
||||
className += ' last';
|
||||
className += ' last'
|
||||
}
|
||||
|
||||
const grip = document.createElement('a');
|
||||
const grip = document.createElement('a')
|
||||
|
||||
grip.className = className;
|
||||
grip.className = className
|
||||
grip.addEventListener('mousedown', (event) => {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
event.preventDefault()
|
||||
event.stopImmediatePropagation()
|
||||
|
||||
this.editor.view.dispatch(selectColumn(index)(this.editor.state.tr));
|
||||
});
|
||||
this.editor.view.dispatch(selectColumn(index)(this.editor.state.tr))
|
||||
})
|
||||
|
||||
return grip;
|
||||
return grip
|
||||
})
|
||||
);
|
||||
});
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return DecorationSet.create(doc, decorations);
|
||||
return DecorationSet.create(doc, decorations)
|
||||
}
|
||||
}
|
||||
})
|
||||
];
|
||||
]
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
export default TableHeader;
|
||||
export default TableHeader
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import TiptapTableRow from '@tiptap/extension-table-row';
|
||||
import TiptapTableRow from '@tiptap/extension-table-row'
|
||||
|
||||
export const TableRow = TiptapTableRow.extend({
|
||||
allowGapCursor: false,
|
||||
content: 'tableCell*'
|
||||
});
|
||||
})
|
||||
|
||||
export default TableRow;
|
||||
export default TableRow
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import TiptapTable from '@tiptap/extension-table';
|
||||
import TiptapTable from '@tiptap/extension-table'
|
||||
|
||||
export const Table = TiptapTable.configure({
|
||||
resizable: true,
|
||||
lastColumnResizable: true,
|
||||
allowTableNodeSelection: true
|
||||
});
|
||||
})
|
||||
|
||||
export default Table;
|
||||
export default Table
|
||||
|
|
|
|||
|
|
@ -1,85 +1,85 @@
|
|||
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';
|
||||
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'
|
||||
|
||||
export const isRectSelected = (rect: Rect) => (selection: CellSelection) => {
|
||||
const map = TableMap.get(selection.$anchorCell.node(-1));
|
||||
const start = selection.$anchorCell.start(-1);
|
||||
const cells = map.cellsInRect(rect);
|
||||
const map = TableMap.get(selection.$anchorCell.node(-1))
|
||||
const start = selection.$anchorCell.start(-1)
|
||||
const cells = map.cellsInRect(rect)
|
||||
const selectedCells = map.cellsInRect(
|
||||
map.rectBetween(selection.$anchorCell.pos - start, selection.$headCell.pos - start)
|
||||
);
|
||||
)
|
||||
|
||||
for (let i = 0, count = cells.length; i < count; i += 1) {
|
||||
if (selectedCells.indexOf(cells[i]) === -1) {
|
||||
return false;
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
return true
|
||||
}
|
||||
|
||||
export const findTable = (selection: Selection) =>
|
||||
findParentNode((node) => node.type.spec.tableRole && node.type.spec.tableRole === 'table')(
|
||||
selection
|
||||
);
|
||||
)
|
||||
|
||||
export const isCellSelection = (selection: Selection): selection is CellSelection =>
|
||||
selection instanceof CellSelection;
|
||||
selection instanceof CellSelection
|
||||
|
||||
export const isColumnSelected = (columnIndex: number) => (selection: Selection) => {
|
||||
if (isCellSelection(selection)) {
|
||||
const map = TableMap.get(selection.$anchorCell.node(-1));
|
||||
const map = TableMap.get(selection.$anchorCell.node(-1))
|
||||
|
||||
return isRectSelected({
|
||||
left: columnIndex,
|
||||
right: columnIndex + 1,
|
||||
top: 0,
|
||||
bottom: map.height
|
||||
})(selection);
|
||||
})(selection)
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
return false
|
||||
}
|
||||
|
||||
export const isRowSelected = (rowIndex: number) => (selection: Selection) => {
|
||||
if (isCellSelection(selection)) {
|
||||
const map = TableMap.get(selection.$anchorCell.node(-1));
|
||||
const map = TableMap.get(selection.$anchorCell.node(-1))
|
||||
|
||||
return isRectSelected({
|
||||
left: 0,
|
||||
right: map.width,
|
||||
top: rowIndex,
|
||||
bottom: rowIndex + 1
|
||||
})(selection);
|
||||
})(selection)
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
return false
|
||||
}
|
||||
|
||||
export const isTableSelected = (selection: Selection) => {
|
||||
if (isCellSelection(selection)) {
|
||||
const map = TableMap.get(selection.$anchorCell.node(-1));
|
||||
const map = TableMap.get(selection.$anchorCell.node(-1))
|
||||
|
||||
return isRectSelected({
|
||||
left: 0,
|
||||
right: map.width,
|
||||
top: 0,
|
||||
bottom: map.height
|
||||
})(selection);
|
||||
})(selection)
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
return false
|
||||
}
|
||||
|
||||
export const getCellsInColumn = (columnIndex: number | number[]) => (selection: Selection) => {
|
||||
const table = findTable(selection);
|
||||
const table = findTable(selection)
|
||||
if (table) {
|
||||
const map = TableMap.get(table.node);
|
||||
const indexes = Array.isArray(columnIndex) ? columnIndex : Array.from([columnIndex]);
|
||||
const map = TableMap.get(table.node)
|
||||
const indexes = Array.isArray(columnIndex) ? columnIndex : Array.from([columnIndex])
|
||||
|
||||
return indexes.reduce(
|
||||
(acc, index) => {
|
||||
|
|
@ -89,32 +89,32 @@ export const getCellsInColumn = (columnIndex: number | number[]) => (selection:
|
|||
right: index + 1,
|
||||
top: 0,
|
||||
bottom: map.height
|
||||
});
|
||||
})
|
||||
|
||||
return acc.concat(
|
||||
cells.map((nodePos) => {
|
||||
const node = table.node.nodeAt(nodePos);
|
||||
const pos = nodePos + table.start;
|
||||
const node = table.node.nodeAt(nodePos)
|
||||
const pos = nodePos + table.start
|
||||
|
||||
return { pos, start: pos + 1, node };
|
||||
return { pos, start: pos + 1, node }
|
||||
})
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
return acc;
|
||||
return acc
|
||||
},
|
||||
[] as { pos: number; start: number; node: Node | null | undefined }[]
|
||||
);
|
||||
)
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return null
|
||||
}
|
||||
|
||||
export const getCellsInRow = (rowIndex: number | number[]) => (selection: Selection) => {
|
||||
const table = findTable(selection);
|
||||
const table = findTable(selection)
|
||||
|
||||
if (table) {
|
||||
const map = TableMap.get(table.node);
|
||||
const indexes = Array.isArray(rowIndex) ? rowIndex : Array.from([rowIndex]);
|
||||
const map = TableMap.get(table.node)
|
||||
const indexes = Array.isArray(rowIndex) ? rowIndex : Array.from([rowIndex])
|
||||
|
||||
return indexes.reduce(
|
||||
(acc, index) => {
|
||||
|
|
@ -124,55 +124,55 @@ export const getCellsInRow = (rowIndex: number | number[]) => (selection: Select
|
|||
right: map.width,
|
||||
top: index,
|
||||
bottom: index + 1
|
||||
});
|
||||
})
|
||||
|
||||
return acc.concat(
|
||||
cells.map((nodePos) => {
|
||||
const node = table.node.nodeAt(nodePos);
|
||||
const pos = nodePos + table.start;
|
||||
return { pos, start: pos + 1, node };
|
||||
const node = table.node.nodeAt(nodePos)
|
||||
const pos = nodePos + table.start
|
||||
return { pos, start: pos + 1, node }
|
||||
})
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
return acc;
|
||||
return acc
|
||||
},
|
||||
[] as { pos: number; start: number; node: Node | null | undefined }[]
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
return null
|
||||
}
|
||||
|
||||
export const getCellsInTable = (selection: Selection) => {
|
||||
const table = findTable(selection);
|
||||
const table = findTable(selection)
|
||||
|
||||
if (table) {
|
||||
const map = TableMap.get(table.node);
|
||||
const map = TableMap.get(table.node)
|
||||
const cells = map.cellsInRect({
|
||||
left: 0,
|
||||
right: map.width,
|
||||
top: 0,
|
||||
bottom: map.height
|
||||
});
|
||||
})
|
||||
|
||||
return cells.map((nodePos) => {
|
||||
const node = table.node.nodeAt(nodePos);
|
||||
const pos = nodePos + table.start;
|
||||
const node = table.node.nodeAt(nodePos)
|
||||
const pos = nodePos + table.start
|
||||
|
||||
return { pos, start: pos + 1, node };
|
||||
});
|
||||
return { pos, start: pos + 1, node }
|
||||
})
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
return null
|
||||
}
|
||||
|
||||
export const findParentNodeClosestToPos = (
|
||||
$pos: ResolvedPos,
|
||||
predicate: (node: Node) => boolean
|
||||
) => {
|
||||
for (let i = $pos.depth; i > 0; i -= 1) {
|
||||
const node = $pos.node(i);
|
||||
const node = $pos.node(i)
|
||||
|
||||
if (predicate(node)) {
|
||||
return {
|
||||
|
|
@ -180,40 +180,40 @@ export const findParentNodeClosestToPos = (
|
|||
start: $pos.start(i),
|
||||
depth: i,
|
||||
node
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
return null
|
||||
}
|
||||
|
||||
export const findCellClosestToPos = ($pos: ResolvedPos) => {
|
||||
const predicate = (node: Node) =>
|
||||
node.type.spec.tableRole && /cell/i.test(node.type.spec.tableRole);
|
||||
node.type.spec.tableRole && /cell/i.test(node.type.spec.tableRole)
|
||||
|
||||
return findParentNodeClosestToPos($pos, predicate);
|
||||
};
|
||||
return findParentNodeClosestToPos($pos, predicate)
|
||||
}
|
||||
|
||||
const select = (type: 'row' | 'column') => (index: number) => (tr: Transaction) => {
|
||||
const table = findTable(tr.selection);
|
||||
const isRowSelection = type === 'row';
|
||||
const table = findTable(tr.selection)
|
||||
const isRowSelection = type === 'row'
|
||||
|
||||
if (table) {
|
||||
const map = TableMap.get(table.node);
|
||||
const map = TableMap.get(table.node)
|
||||
|
||||
// Check if the index is valid
|
||||
if (index >= 0 && index < (isRowSelection ? map.height : map.width)) {
|
||||
const left = isRowSelection ? 0 : index;
|
||||
const top = isRowSelection ? index : 0;
|
||||
const right = isRowSelection ? map.width : index + 1;
|
||||
const bottom = isRowSelection ? index + 1 : map.height;
|
||||
const left = isRowSelection ? 0 : index
|
||||
const top = isRowSelection ? index : 0
|
||||
const right = isRowSelection ? map.width : index + 1
|
||||
const bottom = isRowSelection ? index + 1 : map.height
|
||||
|
||||
const cellsInFirstRow = map.cellsInRect({
|
||||
left,
|
||||
top,
|
||||
right: isRowSelection ? right : left + 1,
|
||||
bottom: isRowSelection ? top + 1 : bottom
|
||||
});
|
||||
})
|
||||
|
||||
const cellsInLastRow =
|
||||
bottom - top === 1
|
||||
|
|
@ -223,41 +223,41 @@ const select = (type: 'row' | 'column') => (index: number) => (tr: Transaction)
|
|||
top: isRowSelection ? bottom - 1 : top,
|
||||
right,
|
||||
bottom
|
||||
});
|
||||
})
|
||||
|
||||
const head = table.start + cellsInFirstRow[0];
|
||||
const anchor = table.start + cellsInLastRow[cellsInLastRow.length - 1];
|
||||
const $head = tr.doc.resolve(head);
|
||||
const $anchor = tr.doc.resolve(anchor);
|
||||
const head = table.start + cellsInFirstRow[0]
|
||||
const anchor = table.start + cellsInLastRow[cellsInLastRow.length - 1]
|
||||
const $head = tr.doc.resolve(head)
|
||||
const $anchor = tr.doc.resolve(anchor)
|
||||
|
||||
return tr.setSelection(new CellSelection($anchor, $head));
|
||||
return tr.setSelection(new CellSelection($anchor, $head))
|
||||
}
|
||||
}
|
||||
return tr;
|
||||
};
|
||||
return tr
|
||||
}
|
||||
|
||||
export const selectColumn = select('column');
|
||||
export const selectColumn = select('column')
|
||||
|
||||
export const selectRow = select('row');
|
||||
export const selectRow = select('row')
|
||||
|
||||
export const selectTable = (tr: Transaction) => {
|
||||
const table = findTable(tr.selection);
|
||||
const table = findTable(tr.selection)
|
||||
|
||||
if (table) {
|
||||
const { map } = TableMap.get(table.node);
|
||||
const { map } = TableMap.get(table.node)
|
||||
|
||||
if (map && map.length) {
|
||||
const head = table.start + map[0];
|
||||
const anchor = table.start + map[map.length - 1];
|
||||
const $head = tr.doc.resolve(head);
|
||||
const $anchor = tr.doc.resolve(anchor);
|
||||
const head = table.start + map[0]
|
||||
const anchor = table.start + map[map.length - 1]
|
||||
const $head = tr.doc.resolve(head)
|
||||
const $anchor = tr.doc.resolve(anchor)
|
||||
|
||||
return tr.setSelection(new CellSelection($anchor, $head));
|
||||
return tr.setSelection(new CellSelection($anchor, $head))
|
||||
}
|
||||
}
|
||||
|
||||
return tr;
|
||||
};
|
||||
return tr
|
||||
}
|
||||
|
||||
export const isColumnGripSelected = ({
|
||||
editor,
|
||||
|
|
@ -265,30 +265,30 @@ export const isColumnGripSelected = ({
|
|||
state,
|
||||
from
|
||||
}: {
|
||||
editor: Editor;
|
||||
view: EditorView;
|
||||
state: EditorState;
|
||||
from: number;
|
||||
editor: Editor
|
||||
view: EditorView
|
||||
state: EditorState
|
||||
from: number
|
||||
}) => {
|
||||
const domAtPos = view.domAtPos(from).node as HTMLElement;
|
||||
const nodeDOM = view.nodeDOM(from) as HTMLElement;
|
||||
const node = nodeDOM || domAtPos;
|
||||
const domAtPos = view.domAtPos(from).node as HTMLElement
|
||||
const nodeDOM = view.nodeDOM(from) as HTMLElement
|
||||
const node = nodeDOM || domAtPos
|
||||
|
||||
if (!editor.isActive(Table.name) || !node || isTableSelected(state.selection)) {
|
||||
return false;
|
||||
return false
|
||||
}
|
||||
|
||||
let container = node;
|
||||
let container = node
|
||||
|
||||
while (container && !['TD', 'TH'].includes(container.tagName)) {
|
||||
container = container.parentElement!;
|
||||
container = container.parentElement!
|
||||
}
|
||||
|
||||
const gripColumn =
|
||||
container && container.querySelector && container.querySelector('a.grip-column.selected');
|
||||
container && container.querySelector && container.querySelector('a.grip-column.selected')
|
||||
|
||||
return !!gripColumn;
|
||||
};
|
||||
return !!gripColumn
|
||||
}
|
||||
|
||||
export const isRowGripSelected = ({
|
||||
editor,
|
||||
|
|
@ -296,27 +296,27 @@ export const isRowGripSelected = ({
|
|||
state,
|
||||
from
|
||||
}: {
|
||||
editor: Editor;
|
||||
view: EditorView;
|
||||
state: EditorState;
|
||||
from: number;
|
||||
editor: Editor
|
||||
view: EditorView
|
||||
state: EditorState
|
||||
from: number
|
||||
}) => {
|
||||
const domAtPos = view.domAtPos(from).node as HTMLElement;
|
||||
const nodeDOM = view.nodeDOM(from) as HTMLElement;
|
||||
const node = nodeDOM || domAtPos;
|
||||
const domAtPos = view.domAtPos(from).node as HTMLElement
|
||||
const nodeDOM = view.nodeDOM(from) as HTMLElement
|
||||
const node = nodeDOM || domAtPos
|
||||
|
||||
if (!editor.isActive(Table.name) || !node || isTableSelected(state.selection)) {
|
||||
return false;
|
||||
return false
|
||||
}
|
||||
|
||||
let container = node;
|
||||
let container = node
|
||||
|
||||
while (container && !['TD', 'TH'].includes(container.tagName)) {
|
||||
container = container.parentElement!;
|
||||
container = container.parentElement!
|
||||
}
|
||||
|
||||
const gripRow =
|
||||
container && container.querySelector && container.querySelector('a.grip-row.selected');
|
||||
container && container.querySelector && container.querySelector('a.grip-row.selected')
|
||||
|
||||
return !!gripRow;
|
||||
};
|
||||
return !!gripRow
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { SvelteNodeViewRenderer } from 'svelte-tiptap';
|
||||
import { Video } from './VideoExtension.js';
|
||||
import type { NodeViewProps } from '@tiptap/core';
|
||||
import type { Component } from 'svelte';
|
||||
import { SvelteNodeViewRenderer } from 'svelte-tiptap'
|
||||
import { Video } from './VideoExtension.js'
|
||||
import type { NodeViewProps } from '@tiptap/core'
|
||||
import type { Component } from 'svelte'
|
||||
|
||||
export const VideoExtended = (content: Component<NodeViewProps>) =>
|
||||
Video.extend({
|
||||
|
|
@ -25,10 +25,10 @@ export const VideoExtended = (content: Component<NodeViewProps>) =>
|
|||
align: {
|
||||
default: 'left'
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
addNodeView: () => {
|
||||
return SvelteNodeViewRenderer(content);
|
||||
return SvelteNodeViewRenderer(content)
|
||||
}
|
||||
});
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { Node, nodeInputRule } from '@tiptap/core';
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state';
|
||||
import { Node, nodeInputRule } from '@tiptap/core'
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
||||
|
||||
export interface VideoOptions {
|
||||
HTMLAttributes: Record<string, unknown>;
|
||||
HTMLAttributes: Record<string, unknown>
|
||||
}
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
|
|
@ -11,20 +11,20 @@ declare module '@tiptap/core' {
|
|||
/**
|
||||
* Set a video node
|
||||
*/
|
||||
setVideo: (src: string) => ReturnType;
|
||||
setVideo: (src: string) => ReturnType
|
||||
/**
|
||||
* Toggle a video
|
||||
*/
|
||||
toggleVideo: (src: string) => ReturnType;
|
||||
toggleVideo: (src: string) => ReturnType
|
||||
/**
|
||||
* Remove a video
|
||||
*/
|
||||
removeVideo: () => ReturnType;
|
||||
};
|
||||
removeVideo: () => ReturnType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const VIDEO_INPUT_REGEX = /!\[(.+|:?)]\((\S+)(?:(?:\s+)["'](\S+)["'])?\)/;
|
||||
const VIDEO_INPUT_REGEX = /!\[(.+|:?)]\((\S+)(?:(?:\s+)["'](\S+)["'])?\)/
|
||||
|
||||
export const Video = Node.create<VideoOptions>({
|
||||
name: 'video',
|
||||
|
|
@ -35,7 +35,7 @@ export const Video = Node.create<VideoOptions>({
|
|||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {}
|
||||
};
|
||||
}
|
||||
},
|
||||
addAttributes() {
|
||||
return {
|
||||
|
|
@ -44,7 +44,7 @@ export const Video = Node.create<VideoOptions>({
|
|||
parseHTML: (el) => (el as HTMLSpanElement).getAttribute('src'),
|
||||
renderHTML: (attrs) => ({ src: attrs.src })
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
parseHTML() {
|
||||
return [
|
||||
|
|
@ -52,7 +52,7 @@ export const Video = Node.create<VideoOptions>({
|
|||
tag: 'video',
|
||||
getAttrs: (el) => ({ src: (el as HTMLVideoElement).getAttribute('src') })
|
||||
}
|
||||
];
|
||||
]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
|
|
@ -60,7 +60,7 @@ export const Video = Node.create<VideoOptions>({
|
|||
'video',
|
||||
{ controls: 'true', style: 'width: fit-content;', ...HTMLAttributes },
|
||||
['source', HTMLAttributes]
|
||||
];
|
||||
]
|
||||
},
|
||||
addCommands() {
|
||||
return {
|
||||
|
|
@ -79,7 +79,7 @@ export const Video = Node.create<VideoOptions>({
|
|||
() =>
|
||||
({ commands }) =>
|
||||
commands.deleteNode(this.name)
|
||||
};
|
||||
}
|
||||
},
|
||||
addInputRules() {
|
||||
return [
|
||||
|
|
@ -87,12 +87,12 @@ export const Video = Node.create<VideoOptions>({
|
|||
find: VIDEO_INPUT_REGEX,
|
||||
type: this.type,
|
||||
getAttributes: (match) => {
|
||||
const [, , src] = match;
|
||||
const [, , src] = match
|
||||
|
||||
return { src };
|
||||
return { src }
|
||||
}
|
||||
})
|
||||
];
|
||||
]
|
||||
},
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
|
|
@ -105,43 +105,43 @@ export const Video = Node.create<VideoOptions>({
|
|||
const {
|
||||
state: { schema, tr },
|
||||
dispatch
|
||||
} = view;
|
||||
} = view
|
||||
const hasFiles =
|
||||
event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length;
|
||||
event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length
|
||||
|
||||
if (!hasFiles) return false;
|
||||
if (!hasFiles) return false
|
||||
|
||||
const videos = Array.from(event.dataTransfer.files).filter((file) =>
|
||||
/video/i.test(file.type)
|
||||
);
|
||||
)
|
||||
|
||||
if (videos.length === 0) return false;
|
||||
if (videos.length === 0) return false
|
||||
|
||||
event.preventDefault();
|
||||
event.preventDefault()
|
||||
|
||||
const coordinates = view.posAtCoords({ left: event.clientX, top: event.clientY });
|
||||
const coordinates = view.posAtCoords({ left: event.clientX, top: event.clientY })
|
||||
|
||||
videos.forEach((video) => {
|
||||
const reader = new FileReader();
|
||||
const reader = new FileReader()
|
||||
|
||||
reader.onload = (readerEvent) => {
|
||||
const node = schema.nodes.video.create({ src: readerEvent.target?.result });
|
||||
const node = schema.nodes.video.create({ src: readerEvent.target?.result })
|
||||
|
||||
if (coordinates && typeof coordinates.pos === 'number') {
|
||||
const transaction = tr.insert(coordinates?.pos, node);
|
||||
const transaction = tr.insert(coordinates?.pos, node)
|
||||
|
||||
dispatch(transaction);
|
||||
dispatch(transaction)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
reader.readAsDataURL(video);
|
||||
});
|
||||
reader.readAsDataURL(video)
|
||||
})
|
||||
|
||||
return true;
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
];
|
||||
]
|
||||
}
|
||||
});
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
import { Editor, Node, mergeAttributes, type CommandProps, type NodeViewProps } from '@tiptap/core';
|
||||
import type { Component } from 'svelte';
|
||||
import { SvelteNodeViewRenderer } from 'svelte-tiptap';
|
||||
import { Editor, Node, mergeAttributes, type CommandProps, type NodeViewProps } from '@tiptap/core'
|
||||
import type { Component } from 'svelte'
|
||||
import { SvelteNodeViewRenderer } from 'svelte-tiptap'
|
||||
|
||||
export interface VideoPlaceholderOptions {
|
||||
HTMLAttributes: Record<string, object>;
|
||||
onDrop: (files: File[], editor: Editor) => void;
|
||||
onDropRejected?: (files: File[], editor: Editor) => void;
|
||||
onEmbed: (url: string, editor: Editor) => void;
|
||||
allowedMimeTypes?: Record<string, string[]>;
|
||||
maxFiles?: number;
|
||||
maxSize?: number;
|
||||
HTMLAttributes: Record<string, object>
|
||||
onDrop: (files: File[], editor: Editor) => void
|
||||
onDropRejected?: (files: File[], editor: Editor) => void
|
||||
onEmbed: (url: string, editor: Editor) => void
|
||||
allowedMimeTypes?: Record<string, string[]>
|
||||
maxFiles?: number
|
||||
maxSize?: number
|
||||
}
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
|
|
@ -18,8 +18,8 @@ declare module '@tiptap/core' {
|
|||
/**
|
||||
* Inserts a video placeholder
|
||||
*/
|
||||
insertVideoPlaceholder: () => ReturnType;
|
||||
};
|
||||
insertVideoPlaceholder: () => ReturnType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -32,14 +32,14 @@ export const VideoPlaceholder = (content: Component<NodeViewProps>) =>
|
|||
onDrop: () => {},
|
||||
onDropRejected: () => {},
|
||||
onEmbed: () => {}
|
||||
};
|
||||
}
|
||||
},
|
||||
parseHTML() {
|
||||
return [{ tag: `div[data-type="${this.name}"]` }];
|
||||
return [{ tag: `div[data-type="${this.name}"]` }]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ['div', mergeAttributes(HTMLAttributes)];
|
||||
return ['div', mergeAttributes(HTMLAttributes)]
|
||||
},
|
||||
group: 'block',
|
||||
draggable: true,
|
||||
|
|
@ -48,15 +48,15 @@ export const VideoPlaceholder = (content: Component<NodeViewProps>) =>
|
|||
isolating: true,
|
||||
|
||||
addNodeView() {
|
||||
return SvelteNodeViewRenderer(content);
|
||||
return SvelteNodeViewRenderer(content)
|
||||
},
|
||||
addCommands() {
|
||||
return {
|
||||
insertVideoPlaceholder: () => (props: CommandProps) => {
|
||||
return props.commands.insertContent({
|
||||
type: 'video-placeholder'
|
||||
});
|
||||
})
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,112 +1,112 @@
|
|||
<script lang="ts">
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { NodeViewWrapper } from 'svelte-tiptap';
|
||||
import type { NodeViewProps } from '@tiptap/core';
|
||||
import { onDestroy, onMount } from 'svelte'
|
||||
import { NodeViewWrapper } from 'svelte-tiptap'
|
||||
import type { NodeViewProps } from '@tiptap/core'
|
||||
|
||||
import AlignLeft from 'lucide-svelte/icons/align-left';
|
||||
import AlignCenter from 'lucide-svelte/icons/align-center';
|
||||
import AlignRight from 'lucide-svelte/icons/align-right';
|
||||
import CopyIcon from 'lucide-svelte/icons/copy';
|
||||
import Fullscreen from 'lucide-svelte/icons/fullscreen';
|
||||
import Trash from 'lucide-svelte/icons/trash';
|
||||
import Captions from 'lucide-svelte/icons/captions';
|
||||
import AlignLeft from 'lucide-svelte/icons/align-left'
|
||||
import AlignCenter from 'lucide-svelte/icons/align-center'
|
||||
import AlignRight from 'lucide-svelte/icons/align-right'
|
||||
import CopyIcon from 'lucide-svelte/icons/copy'
|
||||
import Fullscreen from 'lucide-svelte/icons/fullscreen'
|
||||
import Trash from 'lucide-svelte/icons/trash'
|
||||
import Captions from 'lucide-svelte/icons/captions'
|
||||
|
||||
import { duplicateContent } from '../../utils.js';
|
||||
import { duplicateContent } from '../../utils.js'
|
||||
|
||||
const { node, editor, selected, deleteNode, updateAttributes }: NodeViewProps = $props();
|
||||
const { node, editor, selected, deleteNode, updateAttributes }: NodeViewProps = $props()
|
||||
|
||||
const minWidth = 150;
|
||||
const minWidth = 150
|
||||
|
||||
let audRef: HTMLAudioElement;
|
||||
let nodeRef: HTMLDivElement;
|
||||
let audRef: HTMLAudioElement
|
||||
let nodeRef: HTMLDivElement
|
||||
|
||||
let caption: string | null = $state(node.attrs.title);
|
||||
let caption: string | null = $state(node.attrs.title)
|
||||
$effect(() => {
|
||||
if (caption?.trim() === '') caption = null;
|
||||
updateAttributes({ title: caption });
|
||||
});
|
||||
if (caption?.trim() === '') caption = null
|
||||
updateAttributes({ title: caption })
|
||||
})
|
||||
|
||||
let resizing = $state(false);
|
||||
let resizingInitialWidth = $state(0);
|
||||
let resizingInitialMouseX = $state(0);
|
||||
let resizingPosition = $state<'left' | 'right'>('left');
|
||||
let resizing = $state(false)
|
||||
let resizingInitialWidth = $state(0)
|
||||
let resizingInitialMouseX = $state(0)
|
||||
let resizingPosition = $state<'left' | 'right'>('left')
|
||||
|
||||
function handleResizingPosition(e: MouseEvent, position: 'left' | 'right') {
|
||||
startResize(e);
|
||||
resizingPosition = position;
|
||||
startResize(e)
|
||||
resizingPosition = position
|
||||
}
|
||||
|
||||
function startResize(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
resizing = true;
|
||||
resizingInitialMouseX = e.clientX;
|
||||
if (audRef) resizingInitialWidth = audRef.offsetWidth;
|
||||
e.preventDefault()
|
||||
resizing = true
|
||||
resizingInitialMouseX = e.clientX
|
||||
if (audRef) resizingInitialWidth = audRef.offsetWidth
|
||||
}
|
||||
|
||||
function resize(e: MouseEvent) {
|
||||
if (!resizing) return;
|
||||
let dx = e.clientX - resizingInitialMouseX;
|
||||
if (!resizing) return
|
||||
let dx = e.clientX - resizingInitialMouseX
|
||||
if (resizingPosition === 'left') {
|
||||
dx = resizingInitialMouseX - e.clientX;
|
||||
dx = resizingInitialMouseX - e.clientX
|
||||
}
|
||||
const newWidth = Math.max(resizingInitialWidth + dx, minWidth);
|
||||
const parentWidth = nodeRef?.parentElement?.offsetWidth || 0;
|
||||
const newWidth = Math.max(resizingInitialWidth + dx, minWidth)
|
||||
const parentWidth = nodeRef?.parentElement?.offsetWidth || 0
|
||||
if (newWidth < parentWidth) {
|
||||
updateAttributes({ width: newWidth });
|
||||
updateAttributes({ width: newWidth })
|
||||
}
|
||||
}
|
||||
|
||||
function endResize() {
|
||||
resizing = false;
|
||||
resizingInitialMouseX = 0;
|
||||
resizingInitialWidth = 0;
|
||||
resizing = false
|
||||
resizingInitialMouseX = 0
|
||||
resizingInitialWidth = 0
|
||||
}
|
||||
|
||||
function handleTouchStart(e: TouchEvent, position: 'left' | 'right') {
|
||||
e.preventDefault();
|
||||
resizing = true;
|
||||
resizingPosition = position;
|
||||
resizingInitialMouseX = e.touches[0].clientX;
|
||||
if (audRef) resizingInitialWidth = audRef.offsetWidth;
|
||||
e.preventDefault()
|
||||
resizing = true
|
||||
resizingPosition = position
|
||||
resizingInitialMouseX = e.touches[0].clientX
|
||||
if (audRef) resizingInitialWidth = audRef.offsetWidth
|
||||
}
|
||||
|
||||
function handleTouchMove(e: TouchEvent) {
|
||||
if (!resizing) return;
|
||||
let dx = e.touches[0].clientX - resizingInitialMouseX;
|
||||
if (!resizing) return
|
||||
let dx = e.touches[0].clientX - resizingInitialMouseX
|
||||
if (resizingPosition === 'left') {
|
||||
dx = resizingInitialMouseX - e.touches[0].clientX;
|
||||
dx = resizingInitialMouseX - e.touches[0].clientX
|
||||
}
|
||||
const newWidth = Math.max(resizingInitialWidth + dx, minWidth);
|
||||
const parentWidth = nodeRef?.parentElement?.offsetWidth || 0;
|
||||
const newWidth = Math.max(resizingInitialWidth + dx, minWidth)
|
||||
const parentWidth = nodeRef?.parentElement?.offsetWidth || 0
|
||||
if (newWidth < parentWidth) {
|
||||
updateAttributes({ width: newWidth });
|
||||
updateAttributes({ width: newWidth })
|
||||
}
|
||||
}
|
||||
|
||||
function handleTouchEnd() {
|
||||
resizing = false;
|
||||
resizingInitialMouseX = 0;
|
||||
resizingInitialWidth = 0;
|
||||
resizing = false
|
||||
resizingInitialMouseX = 0
|
||||
resizingInitialWidth = 0
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
// Attach id to nodeRef
|
||||
nodeRef = document.getElementById('resizable-container-audio') as HTMLDivElement;
|
||||
nodeRef = document.getElementById('resizable-container-audio') as HTMLDivElement
|
||||
|
||||
// Mouse events
|
||||
window.addEventListener('mousemove', resize);
|
||||
window.addEventListener('mouseup', endResize);
|
||||
window.addEventListener('mousemove', resize)
|
||||
window.addEventListener('mouseup', endResize)
|
||||
// Touch events
|
||||
window.addEventListener('touchmove', handleTouchMove);
|
||||
window.addEventListener('touchend', handleTouchEnd);
|
||||
});
|
||||
window.addEventListener('touchmove', handleTouchMove)
|
||||
window.addEventListener('touchend', handleTouchEnd)
|
||||
})
|
||||
|
||||
onDestroy(() => {
|
||||
window.removeEventListener('mousemove', resize);
|
||||
window.removeEventListener('mouseup', endResize);
|
||||
window.removeEventListener('touchmove', handleTouchMove);
|
||||
window.removeEventListener('touchend', handleTouchEnd);
|
||||
});
|
||||
window.removeEventListener('mousemove', resize)
|
||||
window.removeEventListener('mouseup', endResize)
|
||||
window.removeEventListener('touchmove', handleTouchMove)
|
||||
window.removeEventListener('touchend', handleTouchEnd)
|
||||
})
|
||||
</script>
|
||||
|
||||
<NodeViewWrapper
|
||||
|
|
@ -136,10 +136,10 @@
|
|||
aria-label="Resize left"
|
||||
class="edra-media-resize-handle edra-media-resize-handle-left"
|
||||
onmousedown={(event: MouseEvent) => {
|
||||
handleResizingPosition(event, 'left');
|
||||
handleResizingPosition(event, 'left')
|
||||
}}
|
||||
ontouchstart={(event: TouchEvent) => {
|
||||
handleTouchStart(event, 'left');
|
||||
handleTouchStart(event, 'left')
|
||||
}}
|
||||
>
|
||||
<div class="edra-media-resize-indicator"></div>
|
||||
|
|
@ -151,10 +151,10 @@
|
|||
aria-label="Resize right"
|
||||
class="edra-media-resize-handle edra-media-resize-handle-right"
|
||||
onmousedown={(event: MouseEvent) => {
|
||||
handleResizingPosition(event, 'right');
|
||||
handleResizingPosition(event, 'right')
|
||||
}}
|
||||
ontouchstart={(event: TouchEvent) => {
|
||||
handleTouchStart(event, 'right');
|
||||
handleTouchStart(event, 'right')
|
||||
}}
|
||||
>
|
||||
<div class="edra-media-resize-indicator"></div>
|
||||
|
|
@ -185,7 +185,7 @@
|
|||
<button
|
||||
class="edra-toolbar-button"
|
||||
onclick={() => {
|
||||
if (caption === null || caption.trim() === '') caption = 'Audio Caption';
|
||||
if (caption === null || caption.trim() === '') caption = 'Audio Caption'
|
||||
}}
|
||||
title="Caption"
|
||||
>
|
||||
|
|
@ -194,7 +194,7 @@
|
|||
<button
|
||||
class="edra-toolbar-button"
|
||||
onclick={() => {
|
||||
duplicateContent(editor, node);
|
||||
duplicateContent(editor, node)
|
||||
}}
|
||||
title="Duplicate"
|
||||
>
|
||||
|
|
@ -205,7 +205,7 @@
|
|||
onclick={() => {
|
||||
updateAttributes({
|
||||
width: 'fit-content'
|
||||
});
|
||||
})
|
||||
}}
|
||||
title="Full Screen"
|
||||
>
|
||||
|
|
@ -214,7 +214,7 @@
|
|||
<button
|
||||
class="edra-toolbar-button edra-destructive"
|
||||
onclick={() => {
|
||||
deleteNode();
|
||||
deleteNode()
|
||||
}}
|
||||
title="Delete"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
<script lang="ts">
|
||||
import type { NodeViewProps } from '@tiptap/core';
|
||||
import AudioLines from 'lucide-svelte/icons/audio-lines';
|
||||
import { NodeViewWrapper } from 'svelte-tiptap';
|
||||
const { editor }: NodeViewProps = $props();
|
||||
import type { NodeViewProps } from '@tiptap/core'
|
||||
import AudioLines from 'lucide-svelte/icons/audio-lines'
|
||||
import { NodeViewWrapper } from 'svelte-tiptap'
|
||||
const { editor }: NodeViewProps = $props()
|
||||
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (!editor.isEditable) return;
|
||||
e.preventDefault();
|
||||
const audioUrl = prompt('Enter the URL of an audio:');
|
||||
if (!editor.isEditable) return
|
||||
e.preventDefault()
|
||||
const audioUrl = prompt('Enter the URL of an audio:')
|
||||
if (!audioUrl) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
editor.chain().focus().setAudio(audioUrl).run();
|
||||
editor.chain().focus().setAudio(audioUrl).run()
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,28 +1,28 @@
|
|||
<script lang="ts">
|
||||
import { NodeViewWrapper, NodeViewContent } from 'svelte-tiptap';
|
||||
import type { NodeViewProps } from '@tiptap/core';
|
||||
const { node, updateAttributes, extension }: NodeViewProps = $props();
|
||||
import { NodeViewWrapper, NodeViewContent } from 'svelte-tiptap'
|
||||
import type { NodeViewProps } from '@tiptap/core'
|
||||
const { node, updateAttributes, extension }: NodeViewProps = $props()
|
||||
|
||||
let preRef = $state<HTMLPreElement>();
|
||||
let preRef = $state<HTMLPreElement>()
|
||||
|
||||
let isCopying = $state(false);
|
||||
let isCopying = $state(false)
|
||||
|
||||
const languages: string[] = extension.options.lowlight.listLanguages().sort();
|
||||
const languages: string[] = extension.options.lowlight.listLanguages().sort()
|
||||
|
||||
let defaultLanguage = $state(node.attrs.language);
|
||||
let defaultLanguage = $state(node.attrs.language)
|
||||
|
||||
$effect(() => {
|
||||
updateAttributes({ language: defaultLanguage });
|
||||
});
|
||||
updateAttributes({ language: defaultLanguage })
|
||||
})
|
||||
|
||||
function copyCode() {
|
||||
if (isCopying) return;
|
||||
if (!preRef) return;
|
||||
isCopying = true;
|
||||
navigator.clipboard.writeText(preRef.innerText);
|
||||
if (isCopying) return
|
||||
if (!preRef) return
|
||||
isCopying = true
|
||||
navigator.clipboard.writeText(preRef.innerText)
|
||||
setTimeout(() => {
|
||||
isCopying = false;
|
||||
}, 1000);
|
||||
isCopying = false
|
||||
}, 1000)
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,27 +1,27 @@
|
|||
<script lang="ts">
|
||||
import type { EdraCommand } from '../../commands/types.js';
|
||||
import type { Editor } from '@tiptap/core';
|
||||
import { icons } from 'lucide-svelte';
|
||||
import type { EdraCommand } from '../../commands/types.js'
|
||||
import type { Editor } from '@tiptap/core'
|
||||
import { icons } from 'lucide-svelte'
|
||||
|
||||
interface Props {
|
||||
command: EdraCommand;
|
||||
editor: Editor;
|
||||
style?: string;
|
||||
onclick?: () => void;
|
||||
command: EdraCommand
|
||||
editor: Editor
|
||||
style?: string
|
||||
onclick?: () => void
|
||||
}
|
||||
|
||||
const { command, editor, style, onclick }: Props = $props();
|
||||
const { command, editor, style, onclick }: Props = $props()
|
||||
|
||||
const Icon = icons[command.iconName];
|
||||
const shortcut = command.shortCuts ? ` (${command.shortCuts[0]})` : '';
|
||||
const Icon = icons[command.iconName]
|
||||
const shortcut = command.shortCuts ? ` (${command.shortCuts[0]})` : ''
|
||||
</script>
|
||||
|
||||
<button
|
||||
class="edra-command-button"
|
||||
class:active={editor.isActive(command.name) || command.isActive?.(editor)}
|
||||
onclick={() => {
|
||||
if (onclick !== undefined) onclick();
|
||||
else command.action(editor);
|
||||
if (onclick !== undefined) onclick()
|
||||
else command.action(editor)
|
||||
}}
|
||||
title={`${command.label}${shortcut}`}
|
||||
{style}
|
||||
|
|
|
|||
|
|
@ -1,112 +1,112 @@
|
|||
<script lang="ts">
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { NodeViewWrapper } from 'svelte-tiptap';
|
||||
import type { NodeViewProps } from '@tiptap/core';
|
||||
import { onDestroy, onMount } from 'svelte'
|
||||
import { NodeViewWrapper } from 'svelte-tiptap'
|
||||
import type { NodeViewProps } from '@tiptap/core'
|
||||
|
||||
import AlignLeft from 'lucide-svelte/icons/align-left';
|
||||
import AlignCenter from 'lucide-svelte/icons/align-center';
|
||||
import AlignRight from 'lucide-svelte/icons/align-right';
|
||||
import CopyIcon from 'lucide-svelte/icons/copy';
|
||||
import Fullscreen from 'lucide-svelte/icons/fullscreen';
|
||||
import Trash from 'lucide-svelte/icons/trash';
|
||||
import Captions from 'lucide-svelte/icons/captions';
|
||||
import AlignLeft from 'lucide-svelte/icons/align-left'
|
||||
import AlignCenter from 'lucide-svelte/icons/align-center'
|
||||
import AlignRight from 'lucide-svelte/icons/align-right'
|
||||
import CopyIcon from 'lucide-svelte/icons/copy'
|
||||
import Fullscreen from 'lucide-svelte/icons/fullscreen'
|
||||
import Trash from 'lucide-svelte/icons/trash'
|
||||
import Captions from 'lucide-svelte/icons/captions'
|
||||
|
||||
import { duplicateContent } from '../../utils.js';
|
||||
import { duplicateContent } from '../../utils.js'
|
||||
|
||||
const { node, editor, selected, deleteNode, updateAttributes }: NodeViewProps = $props();
|
||||
const { node, editor, selected, deleteNode, updateAttributes }: NodeViewProps = $props()
|
||||
|
||||
const minWidth = 150;
|
||||
const minWidth = 150
|
||||
|
||||
let iframeRef: HTMLIFrameElement;
|
||||
let nodeRef: HTMLDivElement;
|
||||
let iframeRef: HTMLIFrameElement
|
||||
let nodeRef: HTMLDivElement
|
||||
|
||||
let caption: string | null = $state(node.attrs.title);
|
||||
let caption: string | null = $state(node.attrs.title)
|
||||
$effect(() => {
|
||||
if (caption?.trim() === '') caption = null;
|
||||
updateAttributes({ title: caption });
|
||||
});
|
||||
if (caption?.trim() === '') caption = null
|
||||
updateAttributes({ title: caption })
|
||||
})
|
||||
|
||||
let resizing = $state(false);
|
||||
let resizingInitialWidth = $state(0);
|
||||
let resizingInitialMouseX = $state(0);
|
||||
let resizingPosition = $state<'left' | 'right'>('left');
|
||||
let resizing = $state(false)
|
||||
let resizingInitialWidth = $state(0)
|
||||
let resizingInitialMouseX = $state(0)
|
||||
let resizingPosition = $state<'left' | 'right'>('left')
|
||||
|
||||
function handleResizingPosition(e: MouseEvent, position: 'left' | 'right') {
|
||||
startResize(e);
|
||||
resizingPosition = position;
|
||||
startResize(e)
|
||||
resizingPosition = position
|
||||
}
|
||||
|
||||
function startResize(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
resizing = true;
|
||||
resizingInitialMouseX = e.clientX;
|
||||
if (iframeRef) resizingInitialWidth = iframeRef.offsetWidth;
|
||||
e.preventDefault()
|
||||
resizing = true
|
||||
resizingInitialMouseX = e.clientX
|
||||
if (iframeRef) resizingInitialWidth = iframeRef.offsetWidth
|
||||
}
|
||||
|
||||
function resize(e: MouseEvent) {
|
||||
if (!resizing) return;
|
||||
let dx = e.clientX - resizingInitialMouseX;
|
||||
if (!resizing) return
|
||||
let dx = e.clientX - resizingInitialMouseX
|
||||
if (resizingPosition === 'left') {
|
||||
dx = resizingInitialMouseX - e.clientX;
|
||||
dx = resizingInitialMouseX - e.clientX
|
||||
}
|
||||
const newWidth = Math.max(resizingInitialWidth + dx, minWidth);
|
||||
const parentWidth = nodeRef?.parentElement?.offsetWidth || 0;
|
||||
const newWidth = Math.max(resizingInitialWidth + dx, minWidth)
|
||||
const parentWidth = nodeRef?.parentElement?.offsetWidth || 0
|
||||
if (newWidth < parentWidth) {
|
||||
updateAttributes({ width: newWidth });
|
||||
updateAttributes({ width: newWidth })
|
||||
}
|
||||
}
|
||||
|
||||
function endResize() {
|
||||
resizing = false;
|
||||
resizingInitialMouseX = 0;
|
||||
resizingInitialWidth = 0;
|
||||
resizing = false
|
||||
resizingInitialMouseX = 0
|
||||
resizingInitialWidth = 0
|
||||
}
|
||||
|
||||
function handleTouchStart(e: TouchEvent, position: 'left' | 'right') {
|
||||
e.preventDefault();
|
||||
resizing = true;
|
||||
resizingPosition = position;
|
||||
resizingInitialMouseX = e.touches[0].clientX;
|
||||
if (iframeRef) resizingInitialWidth = iframeRef.offsetWidth;
|
||||
e.preventDefault()
|
||||
resizing = true
|
||||
resizingPosition = position
|
||||
resizingInitialMouseX = e.touches[0].clientX
|
||||
if (iframeRef) resizingInitialWidth = iframeRef.offsetWidth
|
||||
}
|
||||
|
||||
function handleTouchMove(e: TouchEvent) {
|
||||
if (!resizing) return;
|
||||
let dx = e.touches[0].clientX - resizingInitialMouseX;
|
||||
if (!resizing) return
|
||||
let dx = e.touches[0].clientX - resizingInitialMouseX
|
||||
if (resizingPosition === 'left') {
|
||||
dx = resizingInitialMouseX - e.touches[0].clientX;
|
||||
dx = resizingInitialMouseX - e.touches[0].clientX
|
||||
}
|
||||
const newWidth = Math.max(resizingInitialWidth + dx, minWidth);
|
||||
const parentWidth = nodeRef?.parentElement?.offsetWidth || 0;
|
||||
const newWidth = Math.max(resizingInitialWidth + dx, minWidth)
|
||||
const parentWidth = nodeRef?.parentElement?.offsetWidth || 0
|
||||
if (newWidth < parentWidth) {
|
||||
updateAttributes({ width: newWidth });
|
||||
updateAttributes({ width: newWidth })
|
||||
}
|
||||
}
|
||||
|
||||
function handleTouchEnd() {
|
||||
resizing = false;
|
||||
resizingInitialMouseX = 0;
|
||||
resizingInitialWidth = 0;
|
||||
resizing = false
|
||||
resizingInitialMouseX = 0
|
||||
resizingInitialWidth = 0
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
// Attach id to nodeRef
|
||||
nodeRef = document.getElementById('resizable-container-audio') as HTMLDivElement;
|
||||
nodeRef = document.getElementById('resizable-container-audio') as HTMLDivElement
|
||||
|
||||
// Mouse events
|
||||
window.addEventListener('mousemove', resize);
|
||||
window.addEventListener('mouseup', endResize);
|
||||
window.addEventListener('mousemove', resize)
|
||||
window.addEventListener('mouseup', endResize)
|
||||
// Touch events
|
||||
window.addEventListener('touchmove', handleTouchMove);
|
||||
window.addEventListener('touchend', handleTouchEnd);
|
||||
});
|
||||
window.addEventListener('touchmove', handleTouchMove)
|
||||
window.addEventListener('touchend', handleTouchEnd)
|
||||
})
|
||||
|
||||
onDestroy(() => {
|
||||
window.removeEventListener('mousemove', resize);
|
||||
window.removeEventListener('mouseup', endResize);
|
||||
window.removeEventListener('touchmove', handleTouchMove);
|
||||
window.removeEventListener('touchend', handleTouchEnd);
|
||||
});
|
||||
window.removeEventListener('mousemove', resize)
|
||||
window.removeEventListener('mouseup', endResize)
|
||||
window.removeEventListener('touchmove', handleTouchMove)
|
||||
window.removeEventListener('touchend', handleTouchEnd)
|
||||
})
|
||||
</script>
|
||||
|
||||
<NodeViewWrapper
|
||||
|
|
@ -130,10 +130,10 @@
|
|||
aria-label="Resize left"
|
||||
class="edra-media-resize-handle edra-media-resize-handle-left"
|
||||
onmousedown={(event: MouseEvent) => {
|
||||
handleResizingPosition(event, 'left');
|
||||
handleResizingPosition(event, 'left')
|
||||
}}
|
||||
ontouchstart={(event: TouchEvent) => {
|
||||
handleTouchStart(event, 'left');
|
||||
handleTouchStart(event, 'left')
|
||||
}}
|
||||
>
|
||||
<div class="edra-media-resize-indicator"></div>
|
||||
|
|
@ -145,10 +145,10 @@
|
|||
aria-label="Resize right"
|
||||
class="edra-media-resize-handle edra-media-resize-handle-right"
|
||||
onmousedown={(event: MouseEvent) => {
|
||||
handleResizingPosition(event, 'right');
|
||||
handleResizingPosition(event, 'right')
|
||||
}}
|
||||
ontouchstart={(event: TouchEvent) => {
|
||||
handleTouchStart(event, 'right');
|
||||
handleTouchStart(event, 'right')
|
||||
}}
|
||||
>
|
||||
<div class="edra-media-resize-indicator"></div>
|
||||
|
|
@ -179,7 +179,7 @@
|
|||
<button
|
||||
class="edra-toolbar-button"
|
||||
onclick={() => {
|
||||
if (caption === null || caption.trim() === '') caption = 'Audio Caption';
|
||||
if (caption === null || caption.trim() === '') caption = 'Audio Caption'
|
||||
}}
|
||||
title="Caption"
|
||||
>
|
||||
|
|
@ -188,7 +188,7 @@
|
|||
<button
|
||||
class="edra-toolbar-button"
|
||||
onclick={() => {
|
||||
duplicateContent(editor, node);
|
||||
duplicateContent(editor, node)
|
||||
}}
|
||||
title="Duplicate"
|
||||
>
|
||||
|
|
@ -199,7 +199,7 @@
|
|||
onclick={() => {
|
||||
updateAttributes({
|
||||
width: 'fit-content'
|
||||
});
|
||||
})
|
||||
}}
|
||||
title="Full Screen"
|
||||
>
|
||||
|
|
@ -208,7 +208,7 @@
|
|||
<button
|
||||
class="edra-toolbar-button edra-destructive"
|
||||
onclick={() => {
|
||||
deleteNode();
|
||||
deleteNode()
|
||||
}}
|
||||
title="Delete"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
<script lang="ts">
|
||||
import type { NodeViewProps } from '@tiptap/core';
|
||||
import CodeXML from 'lucide-svelte/icons/code-xml';
|
||||
import { NodeViewWrapper } from 'svelte-tiptap';
|
||||
const { editor }: NodeViewProps = $props();
|
||||
import type { NodeViewProps } from '@tiptap/core'
|
||||
import CodeXML from 'lucide-svelte/icons/code-xml'
|
||||
import { NodeViewWrapper } from 'svelte-tiptap'
|
||||
const { editor }: NodeViewProps = $props()
|
||||
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (!editor.isEditable) return;
|
||||
e.preventDefault();
|
||||
const iFrameURL = prompt('Enter the URL of an iFrame:');
|
||||
if (!editor.isEditable) return
|
||||
e.preventDefault()
|
||||
const iFrameURL = prompt('Enter the URL of an iFrame:')
|
||||
if (!iFrameURL) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
editor.chain().focus().setIframe({ src: iFrameURL }).run();
|
||||
editor.chain().focus().setIframe({ src: iFrameURL }).run()
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,112 +1,112 @@
|
|||
<script lang="ts">
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { NodeViewWrapper } from 'svelte-tiptap';
|
||||
import type { NodeViewProps } from '@tiptap/core';
|
||||
import { onDestroy, onMount } from 'svelte'
|
||||
import { NodeViewWrapper } from 'svelte-tiptap'
|
||||
import type { NodeViewProps } from '@tiptap/core'
|
||||
|
||||
import AlignLeft from 'lucide-svelte/icons/align-left';
|
||||
import AlignCenter from 'lucide-svelte/icons/align-center';
|
||||
import AlignRight from 'lucide-svelte/icons/align-right';
|
||||
import CopyIcon from 'lucide-svelte/icons/copy';
|
||||
import Fullscreen from 'lucide-svelte/icons/fullscreen';
|
||||
import Trash from 'lucide-svelte/icons/trash';
|
||||
import Captions from 'lucide-svelte/icons/captions';
|
||||
import AlignLeft from 'lucide-svelte/icons/align-left'
|
||||
import AlignCenter from 'lucide-svelte/icons/align-center'
|
||||
import AlignRight from 'lucide-svelte/icons/align-right'
|
||||
import CopyIcon from 'lucide-svelte/icons/copy'
|
||||
import Fullscreen from 'lucide-svelte/icons/fullscreen'
|
||||
import Trash from 'lucide-svelte/icons/trash'
|
||||
import Captions from 'lucide-svelte/icons/captions'
|
||||
|
||||
import { duplicateContent } from '../../utils.js';
|
||||
import { duplicateContent } from '../../utils.js'
|
||||
|
||||
const { node, editor, selected, deleteNode, updateAttributes }: NodeViewProps = $props();
|
||||
const { node, editor, selected, deleteNode, updateAttributes }: NodeViewProps = $props()
|
||||
|
||||
const minWidth = 150;
|
||||
const minWidth = 150
|
||||
|
||||
let imgRef: HTMLImageElement;
|
||||
let nodeRef: HTMLDivElement;
|
||||
let imgRef: HTMLImageElement
|
||||
let nodeRef: HTMLDivElement
|
||||
|
||||
let caption: string | null = $state(node.attrs.title);
|
||||
let caption: string | null = $state(node.attrs.title)
|
||||
$effect(() => {
|
||||
if (caption?.trim() === '') caption = null;
|
||||
updateAttributes({ title: caption });
|
||||
});
|
||||
if (caption?.trim() === '') caption = null
|
||||
updateAttributes({ title: caption })
|
||||
})
|
||||
|
||||
let resizing = $state(false);
|
||||
let resizingInitialWidth = $state(0);
|
||||
let resizingInitialMouseX = $state(0);
|
||||
let resizingPosition = $state<'left' | 'right'>('left');
|
||||
let resizing = $state(false)
|
||||
let resizingInitialWidth = $state(0)
|
||||
let resizingInitialMouseX = $state(0)
|
||||
let resizingPosition = $state<'left' | 'right'>('left')
|
||||
|
||||
function handleResizingPosition(e: MouseEvent, position: 'left' | 'right') {
|
||||
startResize(e);
|
||||
resizingPosition = position;
|
||||
startResize(e)
|
||||
resizingPosition = position
|
||||
}
|
||||
|
||||
function startResize(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
resizing = true;
|
||||
resizingInitialMouseX = e.clientX;
|
||||
if (imgRef) resizingInitialWidth = imgRef.offsetWidth;
|
||||
e.preventDefault()
|
||||
resizing = true
|
||||
resizingInitialMouseX = e.clientX
|
||||
if (imgRef) resizingInitialWidth = imgRef.offsetWidth
|
||||
}
|
||||
|
||||
function resize(e: MouseEvent) {
|
||||
if (!resizing) return;
|
||||
let dx = e.clientX - resizingInitialMouseX;
|
||||
if (!resizing) return
|
||||
let dx = e.clientX - resizingInitialMouseX
|
||||
if (resizingPosition === 'left') {
|
||||
dx = resizingInitialMouseX - e.clientX;
|
||||
dx = resizingInitialMouseX - e.clientX
|
||||
}
|
||||
const newWidth = Math.max(resizingInitialWidth + dx, minWidth);
|
||||
const parentWidth = nodeRef?.parentElement?.offsetWidth || 0;
|
||||
const newWidth = Math.max(resizingInitialWidth + dx, minWidth)
|
||||
const parentWidth = nodeRef?.parentElement?.offsetWidth || 0
|
||||
if (newWidth < parentWidth) {
|
||||
updateAttributes({ width: newWidth });
|
||||
updateAttributes({ width: newWidth })
|
||||
}
|
||||
}
|
||||
|
||||
function endResize() {
|
||||
resizing = false;
|
||||
resizingInitialMouseX = 0;
|
||||
resizingInitialWidth = 0;
|
||||
resizing = false
|
||||
resizingInitialMouseX = 0
|
||||
resizingInitialWidth = 0
|
||||
}
|
||||
|
||||
function handleTouchStart(e: TouchEvent, position: 'left' | 'right') {
|
||||
e.preventDefault();
|
||||
resizing = true;
|
||||
resizingPosition = position;
|
||||
resizingInitialMouseX = e.touches[0].clientX;
|
||||
if (imgRef) resizingInitialWidth = imgRef.offsetWidth;
|
||||
e.preventDefault()
|
||||
resizing = true
|
||||
resizingPosition = position
|
||||
resizingInitialMouseX = e.touches[0].clientX
|
||||
if (imgRef) resizingInitialWidth = imgRef.offsetWidth
|
||||
}
|
||||
|
||||
function handleTouchMove(e: TouchEvent) {
|
||||
if (!resizing) return;
|
||||
let dx = e.touches[0].clientX - resizingInitialMouseX;
|
||||
if (!resizing) return
|
||||
let dx = e.touches[0].clientX - resizingInitialMouseX
|
||||
if (resizingPosition === 'left') {
|
||||
dx = resizingInitialMouseX - e.touches[0].clientX;
|
||||
dx = resizingInitialMouseX - e.touches[0].clientX
|
||||
}
|
||||
const newWidth = Math.max(resizingInitialWidth + dx, minWidth);
|
||||
const parentWidth = nodeRef?.parentElement?.offsetWidth || 0;
|
||||
const newWidth = Math.max(resizingInitialWidth + dx, minWidth)
|
||||
const parentWidth = nodeRef?.parentElement?.offsetWidth || 0
|
||||
if (newWidth < parentWidth) {
|
||||
updateAttributes({ width: newWidth });
|
||||
updateAttributes({ width: newWidth })
|
||||
}
|
||||
}
|
||||
|
||||
function handleTouchEnd() {
|
||||
resizing = false;
|
||||
resizingInitialMouseX = 0;
|
||||
resizingInitialWidth = 0;
|
||||
resizing = false
|
||||
resizingInitialMouseX = 0
|
||||
resizingInitialWidth = 0
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
// Attach id to nodeRef
|
||||
nodeRef = document.getElementById('resizable-container-media') as HTMLDivElement;
|
||||
nodeRef = document.getElementById('resizable-container-media') as HTMLDivElement
|
||||
|
||||
// Mouse events
|
||||
window.addEventListener('mousemove', resize);
|
||||
window.addEventListener('mouseup', endResize);
|
||||
window.addEventListener('mousemove', resize)
|
||||
window.addEventListener('mouseup', endResize)
|
||||
// Touch events
|
||||
window.addEventListener('touchmove', handleTouchMove);
|
||||
window.addEventListener('touchend', handleTouchEnd);
|
||||
});
|
||||
window.addEventListener('touchmove', handleTouchMove)
|
||||
window.addEventListener('touchend', handleTouchEnd)
|
||||
})
|
||||
|
||||
onDestroy(() => {
|
||||
window.removeEventListener('mousemove', resize);
|
||||
window.removeEventListener('mouseup', endResize);
|
||||
window.removeEventListener('touchmove', handleTouchMove);
|
||||
window.removeEventListener('touchend', handleTouchEnd);
|
||||
});
|
||||
window.removeEventListener('mousemove', resize)
|
||||
window.removeEventListener('mouseup', endResize)
|
||||
window.removeEventListener('touchmove', handleTouchMove)
|
||||
window.removeEventListener('touchend', handleTouchEnd)
|
||||
})
|
||||
</script>
|
||||
|
||||
<NodeViewWrapper
|
||||
|
|
@ -133,10 +133,10 @@
|
|||
aria-label="Resize left"
|
||||
class="edra-media-resize-handle edra-media-resize-handle-left"
|
||||
onmousedown={(event: MouseEvent) => {
|
||||
handleResizingPosition(event, 'left');
|
||||
handleResizingPosition(event, 'left')
|
||||
}}
|
||||
ontouchstart={(event: TouchEvent) => {
|
||||
handleTouchStart(event, 'left');
|
||||
handleTouchStart(event, 'left')
|
||||
}}
|
||||
>
|
||||
<div class="edra-media-resize-indicator"></div>
|
||||
|
|
@ -148,10 +148,10 @@
|
|||
aria-label="Resize right"
|
||||
class="edra-media-resize-handle edra-media-resize-handle-right"
|
||||
onmousedown={(event: MouseEvent) => {
|
||||
handleResizingPosition(event, 'right');
|
||||
handleResizingPosition(event, 'right')
|
||||
}}
|
||||
ontouchstart={(event: TouchEvent) => {
|
||||
handleTouchStart(event, 'right');
|
||||
handleTouchStart(event, 'right')
|
||||
}}
|
||||
>
|
||||
<div class="edra-media-resize-indicator"></div>
|
||||
|
|
@ -182,7 +182,7 @@
|
|||
<button
|
||||
class="edra-toolbar-button"
|
||||
onclick={() => {
|
||||
if (caption === null || caption.trim() === '') caption = 'Image Caption';
|
||||
if (caption === null || caption.trim() === '') caption = 'Image Caption'
|
||||
}}
|
||||
title="Caption"
|
||||
>
|
||||
|
|
@ -191,7 +191,7 @@
|
|||
<button
|
||||
class="edra-toolbar-button"
|
||||
onclick={() => {
|
||||
duplicateContent(editor, node);
|
||||
duplicateContent(editor, node)
|
||||
}}
|
||||
title="Duplicate"
|
||||
>
|
||||
|
|
@ -202,7 +202,7 @@
|
|||
onclick={() => {
|
||||
updateAttributes({
|
||||
width: 'fit-content'
|
||||
});
|
||||
})
|
||||
}}
|
||||
title="Full Screen"
|
||||
>
|
||||
|
|
@ -211,7 +211,7 @@
|
|||
<button
|
||||
class="edra-toolbar-button edra-destructive"
|
||||
onclick={() => {
|
||||
deleteNode();
|
||||
deleteNode()
|
||||
}}
|
||||
title="Delete"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
<script lang="ts">
|
||||
import type { NodeViewProps } from '@tiptap/core';
|
||||
import Image from 'lucide-svelte/icons/image';
|
||||
import { NodeViewWrapper } from 'svelte-tiptap';
|
||||
const { editor }: NodeViewProps = $props();
|
||||
import type { NodeViewProps } from '@tiptap/core'
|
||||
import Image from 'lucide-svelte/icons/image'
|
||||
import { NodeViewWrapper } from 'svelte-tiptap'
|
||||
const { editor }: NodeViewProps = $props()
|
||||
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (!editor.isEditable) return;
|
||||
e.preventDefault();
|
||||
const imageUrl = prompt('Enter the URL of an image:');
|
||||
if (!editor.isEditable) return
|
||||
e.preventDefault()
|
||||
const imageUrl = prompt('Enter the URL of an image:')
|
||||
if (!imageUrl) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
editor.chain().focus().setImage({ src: imageUrl }).run();
|
||||
editor.chain().focus().setImage({ src: imageUrl }).run()
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,75 +1,75 @@
|
|||
<script lang="ts">
|
||||
import type { Editor } from '@tiptap/core';
|
||||
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
|
||||
import ArrowRight from 'lucide-svelte/icons/arrow-right';
|
||||
import CaseSensitive from 'lucide-svelte/icons/case-sensitive';
|
||||
import Replace from 'lucide-svelte/icons/replace';
|
||||
import ReplaceAll from 'lucide-svelte/icons/replace-all';
|
||||
import Search from 'lucide-svelte/icons/search';
|
||||
import type { Editor } from '@tiptap/core'
|
||||
import ArrowLeft from 'lucide-svelte/icons/arrow-left'
|
||||
import ArrowRight from 'lucide-svelte/icons/arrow-right'
|
||||
import CaseSensitive from 'lucide-svelte/icons/case-sensitive'
|
||||
import Replace from 'lucide-svelte/icons/replace'
|
||||
import ReplaceAll from 'lucide-svelte/icons/replace-all'
|
||||
import Search from 'lucide-svelte/icons/search'
|
||||
|
||||
interface Props {
|
||||
editor: Editor;
|
||||
show: boolean;
|
||||
editor: Editor
|
||||
show: boolean
|
||||
}
|
||||
|
||||
let { editor, show = $bindable(false) }: Props = $props();
|
||||
let { editor, show = $bindable(false) }: Props = $props()
|
||||
|
||||
let searchText = $state('');
|
||||
let replaceText = $state('');
|
||||
let caseSensitive = $state(false);
|
||||
let searchText = $state('')
|
||||
let replaceText = $state('')
|
||||
let caseSensitive = $state(false)
|
||||
|
||||
let searchIndex = $derived(editor.storage?.searchAndReplace?.resultIndex);
|
||||
let searchCount = $derived(editor.storage?.searchAndReplace?.results.length);
|
||||
let searchIndex = $derived(editor.storage?.searchAndReplace?.resultIndex)
|
||||
let searchCount = $derived(editor.storage?.searchAndReplace?.results.length)
|
||||
|
||||
function updateSearchTerm(clearIndex: boolean = false) {
|
||||
if (clearIndex) editor.commands.resetIndex();
|
||||
if (clearIndex) editor.commands.resetIndex()
|
||||
|
||||
editor.commands.setSearchTerm(searchText);
|
||||
editor.commands.setReplaceTerm(replaceText);
|
||||
editor.commands.setCaseSensitive(caseSensitive);
|
||||
editor.commands.setSearchTerm(searchText)
|
||||
editor.commands.setReplaceTerm(replaceText)
|
||||
editor.commands.setCaseSensitive(caseSensitive)
|
||||
}
|
||||
|
||||
function goToSelection() {
|
||||
const { results, resultIndex } = editor.storage.searchAndReplace;
|
||||
const position = results[resultIndex];
|
||||
if (!position) return;
|
||||
editor.commands.setTextSelection(position);
|
||||
const { node } = editor.view.domAtPos(editor.state.selection.anchor);
|
||||
if (node instanceof HTMLElement) node.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
const { results, resultIndex } = editor.storage.searchAndReplace
|
||||
const position = results[resultIndex]
|
||||
if (!position) return
|
||||
editor.commands.setTextSelection(position)
|
||||
const { node } = editor.view.domAtPos(editor.state.selection.anchor)
|
||||
if (node instanceof HTMLElement) node.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}
|
||||
|
||||
function replace() {
|
||||
editor.commands.replace();
|
||||
goToSelection();
|
||||
editor.commands.replace()
|
||||
goToSelection()
|
||||
}
|
||||
|
||||
const next = () => {
|
||||
editor.commands.nextSearchResult();
|
||||
goToSelection();
|
||||
};
|
||||
editor.commands.nextSearchResult()
|
||||
goToSelection()
|
||||
}
|
||||
|
||||
const previous = () => {
|
||||
editor.commands.previousSearchResult();
|
||||
goToSelection();
|
||||
};
|
||||
editor.commands.previousSearchResult()
|
||||
goToSelection()
|
||||
}
|
||||
|
||||
const clear = () => {
|
||||
searchText = '';
|
||||
replaceText = '';
|
||||
caseSensitive = false;
|
||||
editor.commands.resetIndex();
|
||||
};
|
||||
searchText = ''
|
||||
replaceText = ''
|
||||
caseSensitive = false
|
||||
editor.commands.resetIndex()
|
||||
}
|
||||
|
||||
const replaceAll = () => editor.commands.replaceAll();
|
||||
const replaceAll = () => editor.commands.replaceAll()
|
||||
</script>
|
||||
|
||||
<div class="edra-search-and-replace">
|
||||
<button
|
||||
class="edra-command-button"
|
||||
onclick={() => {
|
||||
show = !show;
|
||||
clear();
|
||||
updateSearchTerm();
|
||||
show = !show
|
||||
clear()
|
||||
updateSearchTerm()
|
||||
}}
|
||||
title={show ? 'Go Back' : 'Search and Replace'}
|
||||
>
|
||||
|
|
@ -87,8 +87,8 @@
|
|||
class="edra-command-button"
|
||||
class:active={caseSensitive}
|
||||
onclick={() => {
|
||||
caseSensitive = !caseSensitive;
|
||||
updateSearchTerm();
|
||||
caseSensitive = !caseSensitive
|
||||
updateSearchTerm()
|
||||
}}
|
||||
title="Case Sensitive"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,92 +1,92 @@
|
|||
<script lang="ts">
|
||||
import { icons } from 'lucide-svelte';
|
||||
import { icons } from 'lucide-svelte'
|
||||
|
||||
interface Props {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
props: Record<string, any>;
|
||||
props: Record<string, any>
|
||||
}
|
||||
|
||||
const { props }: Props = $props();
|
||||
const { props }: Props = $props()
|
||||
|
||||
let scrollContainer = $state<HTMLElement | null>(null);
|
||||
let scrollContainer = $state<HTMLElement | null>(null)
|
||||
|
||||
let selectedGroupIndex = $state<number>(0);
|
||||
let selectedCommandIndex = $state<number>(0);
|
||||
let selectedGroupIndex = $state<number>(0)
|
||||
let selectedCommandIndex = $state<number>(0)
|
||||
|
||||
const items = $derived.by(() => props.items);
|
||||
const items = $derived.by(() => props.items)
|
||||
|
||||
$effect(() => {
|
||||
if (items) {
|
||||
selectedGroupIndex = 0;
|
||||
selectedCommandIndex = 0;
|
||||
selectedGroupIndex = 0
|
||||
selectedCommandIndex = 0
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
const activeItem = document.getElementById(`${selectedGroupIndex}-${selectedCommandIndex}`);
|
||||
const activeItem = document.getElementById(`${selectedGroupIndex}-${selectedCommandIndex}`)
|
||||
if (activeItem !== null && scrollContainer !== null) {
|
||||
const offsetTop = activeItem.offsetTop;
|
||||
const offsetHeight = activeItem.offsetHeight;
|
||||
scrollContainer.scrollTop = offsetTop - offsetHeight;
|
||||
const offsetTop = activeItem.offsetTop
|
||||
const offsetHeight = activeItem.offsetHeight
|
||||
scrollContainer.scrollTop = offsetTop - offsetHeight
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
const selectItem = (groupIndex: number, commandIndex: number) => {
|
||||
const command = props.items[groupIndex].commands[commandIndex];
|
||||
props.command(command);
|
||||
};
|
||||
const command = props.items[groupIndex].commands[commandIndex]
|
||||
props.command(command)
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'ArrowDown' || ((e.ctrlKey || e.metaKey) && e.key === 'j') || e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
e.preventDefault()
|
||||
if (!props.items.length) {
|
||||
return false;
|
||||
return false
|
||||
}
|
||||
const commands = props.items[selectedGroupIndex].commands;
|
||||
let newCommandIndex = selectedCommandIndex + 1;
|
||||
let newGroupIndex = selectedGroupIndex;
|
||||
const commands = props.items[selectedGroupIndex].commands
|
||||
let newCommandIndex = selectedCommandIndex + 1
|
||||
let newGroupIndex = selectedGroupIndex
|
||||
if (commands.length - 1 < newCommandIndex) {
|
||||
newCommandIndex = 0;
|
||||
newGroupIndex = selectedGroupIndex + 1;
|
||||
newCommandIndex = 0
|
||||
newGroupIndex = selectedGroupIndex + 1
|
||||
}
|
||||
|
||||
if (props.items.length - 1 < newGroupIndex) {
|
||||
newGroupIndex = 0;
|
||||
newGroupIndex = 0
|
||||
}
|
||||
selectedCommandIndex = newCommandIndex;
|
||||
selectedGroupIndex = newGroupIndex;
|
||||
return true;
|
||||
selectedCommandIndex = newCommandIndex
|
||||
selectedGroupIndex = newGroupIndex
|
||||
return true
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowUp' || ((e.ctrlKey || e.metaKey) && e.key === 'k')) {
|
||||
e.preventDefault();
|
||||
e.preventDefault()
|
||||
if (!props.items.length) {
|
||||
return false;
|
||||
return false
|
||||
}
|
||||
let newCommandIndex = selectedCommandIndex - 1;
|
||||
let newGroupIndex = selectedGroupIndex;
|
||||
let newCommandIndex = selectedCommandIndex - 1
|
||||
let newGroupIndex = selectedGroupIndex
|
||||
if (newCommandIndex < 0) {
|
||||
newGroupIndex = selectedGroupIndex - 1;
|
||||
newCommandIndex = props.items[newGroupIndex]?.commands.length - 1 || 0;
|
||||
newGroupIndex = selectedGroupIndex - 1
|
||||
newCommandIndex = props.items[newGroupIndex]?.commands.length - 1 || 0
|
||||
}
|
||||
if (newGroupIndex < 0) {
|
||||
newGroupIndex = props.items.length - 1;
|
||||
newCommandIndex = props.items[newGroupIndex].commands.length - 1;
|
||||
newGroupIndex = props.items.length - 1
|
||||
newCommandIndex = props.items[newGroupIndex].commands.length - 1
|
||||
}
|
||||
selectedCommandIndex = newCommandIndex;
|
||||
selectedGroupIndex = newGroupIndex;
|
||||
return true;
|
||||
selectedCommandIndex = newCommandIndex
|
||||
selectedGroupIndex = newGroupIndex
|
||||
return true
|
||||
}
|
||||
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
e.preventDefault()
|
||||
if (!props.items.length || selectedGroupIndex === -1 || selectedCommandIndex === -1) {
|
||||
return false;
|
||||
return false
|
||||
}
|
||||
selectItem(selectedGroupIndex, selectedCommandIndex);
|
||||
return true;
|
||||
selectItem(selectedGroupIndex, selectedCommandIndex)
|
||||
return true
|
||||
}
|
||||
return false;
|
||||
return false
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,112 +1,112 @@
|
|||
<script lang="ts">
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { NodeViewWrapper } from 'svelte-tiptap';
|
||||
import type { NodeViewProps } from '@tiptap/core';
|
||||
import { onDestroy, onMount } from 'svelte'
|
||||
import { NodeViewWrapper } from 'svelte-tiptap'
|
||||
import type { NodeViewProps } from '@tiptap/core'
|
||||
|
||||
import AlignLeft from 'lucide-svelte/icons/align-left';
|
||||
import AlignCenter from 'lucide-svelte/icons/align-center';
|
||||
import AlignRight from 'lucide-svelte/icons/align-right';
|
||||
import CopyIcon from 'lucide-svelte/icons/copy';
|
||||
import Fullscreen from 'lucide-svelte/icons/fullscreen';
|
||||
import Trash from 'lucide-svelte/icons/trash';
|
||||
import Captions from 'lucide-svelte/icons/captions';
|
||||
import AlignLeft from 'lucide-svelte/icons/align-left'
|
||||
import AlignCenter from 'lucide-svelte/icons/align-center'
|
||||
import AlignRight from 'lucide-svelte/icons/align-right'
|
||||
import CopyIcon from 'lucide-svelte/icons/copy'
|
||||
import Fullscreen from 'lucide-svelte/icons/fullscreen'
|
||||
import Trash from 'lucide-svelte/icons/trash'
|
||||
import Captions from 'lucide-svelte/icons/captions'
|
||||
|
||||
import { duplicateContent } from '../../utils.js';
|
||||
import { duplicateContent } from '../../utils.js'
|
||||
|
||||
const { node, editor, selected, deleteNode, updateAttributes }: NodeViewProps = $props();
|
||||
const { node, editor, selected, deleteNode, updateAttributes }: NodeViewProps = $props()
|
||||
|
||||
const minWidth = 150;
|
||||
const minWidth = 150
|
||||
|
||||
let vidRef: HTMLVideoElement;
|
||||
let nodeRef: HTMLDivElement;
|
||||
let vidRef: HTMLVideoElement
|
||||
let nodeRef: HTMLDivElement
|
||||
|
||||
let caption: string | null = $state(node.attrs.title);
|
||||
let caption: string | null = $state(node.attrs.title)
|
||||
$effect(() => {
|
||||
if (caption?.trim() === '') caption = null;
|
||||
updateAttributes({ title: caption });
|
||||
});
|
||||
if (caption?.trim() === '') caption = null
|
||||
updateAttributes({ title: caption })
|
||||
})
|
||||
|
||||
let resizing = $state(false);
|
||||
let resizingInitialWidth = $state(0);
|
||||
let resizingInitialMouseX = $state(0);
|
||||
let resizingPosition = $state<'left' | 'right'>('left');
|
||||
let resizing = $state(false)
|
||||
let resizingInitialWidth = $state(0)
|
||||
let resizingInitialMouseX = $state(0)
|
||||
let resizingPosition = $state<'left' | 'right'>('left')
|
||||
|
||||
function handleResizingPosition(e: MouseEvent, position: 'left' | 'right') {
|
||||
startResize(e);
|
||||
resizingPosition = position;
|
||||
startResize(e)
|
||||
resizingPosition = position
|
||||
}
|
||||
|
||||
function startResize(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
resizing = true;
|
||||
resizingInitialMouseX = e.clientX;
|
||||
if (vidRef) resizingInitialWidth = vidRef.offsetWidth;
|
||||
e.preventDefault()
|
||||
resizing = true
|
||||
resizingInitialMouseX = e.clientX
|
||||
if (vidRef) resizingInitialWidth = vidRef.offsetWidth
|
||||
}
|
||||
|
||||
function resize(e: MouseEvent) {
|
||||
if (!resizing) return;
|
||||
let dx = e.clientX - resizingInitialMouseX;
|
||||
if (!resizing) return
|
||||
let dx = e.clientX - resizingInitialMouseX
|
||||
if (resizingPosition === 'left') {
|
||||
dx = resizingInitialMouseX - e.clientX;
|
||||
dx = resizingInitialMouseX - e.clientX
|
||||
}
|
||||
const newWidth = Math.max(resizingInitialWidth + dx, minWidth);
|
||||
const parentWidth = nodeRef?.parentElement?.offsetWidth || 0;
|
||||
const newWidth = Math.max(resizingInitialWidth + dx, minWidth)
|
||||
const parentWidth = nodeRef?.parentElement?.offsetWidth || 0
|
||||
if (newWidth < parentWidth) {
|
||||
updateAttributes({ width: newWidth });
|
||||
updateAttributes({ width: newWidth })
|
||||
}
|
||||
}
|
||||
|
||||
function endResize() {
|
||||
resizing = false;
|
||||
resizingInitialMouseX = 0;
|
||||
resizingInitialWidth = 0;
|
||||
resizing = false
|
||||
resizingInitialMouseX = 0
|
||||
resizingInitialWidth = 0
|
||||
}
|
||||
|
||||
function handleTouchStart(e: TouchEvent, position: 'left' | 'right') {
|
||||
e.preventDefault();
|
||||
resizing = true;
|
||||
resizingPosition = position;
|
||||
resizingInitialMouseX = e.touches[0].clientX;
|
||||
if (vidRef) resizingInitialWidth = vidRef.offsetWidth;
|
||||
e.preventDefault()
|
||||
resizing = true
|
||||
resizingPosition = position
|
||||
resizingInitialMouseX = e.touches[0].clientX
|
||||
if (vidRef) resizingInitialWidth = vidRef.offsetWidth
|
||||
}
|
||||
|
||||
function handleTouchMove(e: TouchEvent) {
|
||||
if (!resizing) return;
|
||||
let dx = e.touches[0].clientX - resizingInitialMouseX;
|
||||
if (!resizing) return
|
||||
let dx = e.touches[0].clientX - resizingInitialMouseX
|
||||
if (resizingPosition === 'left') {
|
||||
dx = resizingInitialMouseX - e.touches[0].clientX;
|
||||
dx = resizingInitialMouseX - e.touches[0].clientX
|
||||
}
|
||||
const newWidth = Math.max(resizingInitialWidth + dx, minWidth);
|
||||
const parentWidth = nodeRef?.parentElement?.offsetWidth || 0;
|
||||
const newWidth = Math.max(resizingInitialWidth + dx, minWidth)
|
||||
const parentWidth = nodeRef?.parentElement?.offsetWidth || 0
|
||||
if (newWidth < parentWidth) {
|
||||
updateAttributes({ width: newWidth });
|
||||
updateAttributes({ width: newWidth })
|
||||
}
|
||||
}
|
||||
|
||||
function handleTouchEnd() {
|
||||
resizing = false;
|
||||
resizingInitialMouseX = 0;
|
||||
resizingInitialWidth = 0;
|
||||
resizing = false
|
||||
resizingInitialMouseX = 0
|
||||
resizingInitialWidth = 0
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
// Attach id to nodeRef
|
||||
nodeRef = document.getElementById('resizable-container-media') as HTMLDivElement;
|
||||
nodeRef = document.getElementById('resizable-container-media') as HTMLDivElement
|
||||
|
||||
// Mouse events
|
||||
window.addEventListener('mousemove', resize);
|
||||
window.addEventListener('mouseup', endResize);
|
||||
window.addEventListener('mousemove', resize)
|
||||
window.addEventListener('mouseup', endResize)
|
||||
// Touch events
|
||||
window.addEventListener('touchmove', handleTouchMove);
|
||||
window.addEventListener('touchend', handleTouchEnd);
|
||||
});
|
||||
window.addEventListener('touchmove', handleTouchMove)
|
||||
window.addEventListener('touchend', handleTouchEnd)
|
||||
})
|
||||
|
||||
onDestroy(() => {
|
||||
window.removeEventListener('mousemove', resize);
|
||||
window.removeEventListener('mouseup', endResize);
|
||||
window.removeEventListener('touchmove', handleTouchMove);
|
||||
window.removeEventListener('touchend', handleTouchEnd);
|
||||
});
|
||||
window.removeEventListener('mousemove', resize)
|
||||
window.removeEventListener('mouseup', endResize)
|
||||
window.removeEventListener('touchmove', handleTouchMove)
|
||||
window.removeEventListener('touchend', handleTouchEnd)
|
||||
})
|
||||
</script>
|
||||
|
||||
<NodeViewWrapper
|
||||
|
|
@ -136,10 +136,10 @@
|
|||
aria-label="Resize left"
|
||||
class="edra-media-resize-handle edra-media-resize-handle-left"
|
||||
onmousedown={(event: MouseEvent) => {
|
||||
handleResizingPosition(event, 'left');
|
||||
handleResizingPosition(event, 'left')
|
||||
}}
|
||||
ontouchstart={(event: TouchEvent) => {
|
||||
handleTouchStart(event, 'left');
|
||||
handleTouchStart(event, 'left')
|
||||
}}
|
||||
>
|
||||
<div class="edra-media-resize-indicator"></div>
|
||||
|
|
@ -151,10 +151,10 @@
|
|||
aria-label="Resize right"
|
||||
class="edra-media-resize-handle edra-media-resize-handle-right"
|
||||
onmousedown={(event: MouseEvent) => {
|
||||
handleResizingPosition(event, 'right');
|
||||
handleResizingPosition(event, 'right')
|
||||
}}
|
||||
ontouchstart={(event: TouchEvent) => {
|
||||
handleTouchStart(event, 'right');
|
||||
handleTouchStart(event, 'right')
|
||||
}}
|
||||
>
|
||||
<div class="edra-media-resize-indicator"></div>
|
||||
|
|
@ -185,7 +185,7 @@
|
|||
<button
|
||||
class="edra-toolbar-button"
|
||||
onclick={() => {
|
||||
if (caption === null || caption.trim() === '') caption = 'Video Caption';
|
||||
if (caption === null || caption.trim() === '') caption = 'Video Caption'
|
||||
}}
|
||||
title="Caption"
|
||||
>
|
||||
|
|
@ -194,7 +194,7 @@
|
|||
<button
|
||||
class="edra-toolbar-button"
|
||||
onclick={() => {
|
||||
duplicateContent(editor, node);
|
||||
duplicateContent(editor, node)
|
||||
}}
|
||||
title="Duplicate"
|
||||
>
|
||||
|
|
@ -205,7 +205,7 @@
|
|||
onclick={() => {
|
||||
updateAttributes({
|
||||
width: 'fit-content'
|
||||
});
|
||||
})
|
||||
}}
|
||||
title="Full Screen"
|
||||
>
|
||||
|
|
@ -214,7 +214,7 @@
|
|||
<button
|
||||
class="edra-toolbar-button edra-destructive"
|
||||
onclick={() => {
|
||||
deleteNode();
|
||||
deleteNode()
|
||||
}}
|
||||
title="Delete"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
<script lang="ts">
|
||||
import type { NodeViewProps } from '@tiptap/core';
|
||||
import Video from 'lucide-svelte/icons/video';
|
||||
import { NodeViewWrapper } from 'svelte-tiptap';
|
||||
const { editor }: NodeViewProps = $props();
|
||||
import type { NodeViewProps } from '@tiptap/core'
|
||||
import Video from 'lucide-svelte/icons/video'
|
||||
import { NodeViewWrapper } from 'svelte-tiptap'
|
||||
const { editor }: NodeViewProps = $props()
|
||||
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (!editor.isEditable) return;
|
||||
e.preventDefault();
|
||||
const videoUrl = prompt('Enter the URL of the video:');
|
||||
if (!editor.isEditable) return
|
||||
e.preventDefault()
|
||||
const videoUrl = prompt('Enter the URL of the video:')
|
||||
if (!videoUrl) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
editor.chain().focus().setVideo(videoUrl).run();
|
||||
editor.chain().focus().setVideo(videoUrl).run()
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,43 +1,43 @@
|
|||
<script lang="ts">
|
||||
import { type Editor } from '@tiptap/core';
|
||||
import { onMount } from 'svelte';
|
||||
import { type Editor } from '@tiptap/core'
|
||||
import { onMount } from 'svelte'
|
||||
|
||||
import { initiateEditor } from '../editor.js';
|
||||
import './style.css';
|
||||
import 'katex/dist/katex.min.css';
|
||||
import { initiateEditor } from '../editor.js'
|
||||
import './style.css'
|
||||
import 'katex/dist/katex.min.css'
|
||||
|
||||
// Lowlight
|
||||
import { CodeBlockLowlight } from '@tiptap/extension-code-block-lowlight';
|
||||
import { all, createLowlight } from 'lowlight';
|
||||
import '../editor.css';
|
||||
import '../onedark.css';
|
||||
import { SvelteNodeViewRenderer } from 'svelte-tiptap';
|
||||
import CodeExtended from './components/CodeExtended.svelte';
|
||||
import { AudioPlaceholder } from '../extensions/audio/AudioPlaceholder.js';
|
||||
import AudioPlaceholderComponent from './components/AudioPlaceholder.svelte';
|
||||
import AudioExtendedComponent from './components/AudioExtended.svelte';
|
||||
import { ImagePlaceholder } from '../extensions/image/ImagePlaceholder.js';
|
||||
import ImagePlaceholderComponent from './components/ImagePlaceholder.svelte';
|
||||
import { VideoPlaceholder } from '../extensions/video/VideoPlaceholder.js';
|
||||
import VideoPlaceholderComponent from './components/VideoPlaceholder.svelte';
|
||||
import { ImageExtended } from '../extensions/image/ImageExtended.js';
|
||||
import ImageExtendedComponent from './components/ImageExtended.svelte';
|
||||
import VideoExtendedComponent from './components/VideoExtended.svelte';
|
||||
import { VideoExtended } from '../extensions/video/VideoExtended.js';
|
||||
import { AudioExtended } from '../extensions/audio/AudiExtended.js';
|
||||
import LinkMenu from './menus/link-menu.svelte';
|
||||
import TableRowMenu from './menus/table/table-row-menu.svelte';
|
||||
import TableColMenu from './menus/table/table-col-menu.svelte';
|
||||
import slashcommand from '../extensions/slash-command/slashcommand.js';
|
||||
import SlashCommandList from './components/SlashCommandList.svelte';
|
||||
import LoaderCircle from 'lucide-svelte/icons/loader-circle';
|
||||
import { focusEditor, type EdraProps } from '../utils.js';
|
||||
import IFramePlaceholderComponent from './components/IFramePlaceholder.svelte';
|
||||
import { IFramePlaceholder } from '../extensions/iframe/IFramePlaceholder.js';
|
||||
import { IFrameExtended } from '../extensions/iframe/IFrameExtended.js';
|
||||
import IFrameExtendedComponent from './components/IFrameExtended.svelte';
|
||||
import { CodeBlockLowlight } from '@tiptap/extension-code-block-lowlight'
|
||||
import { all, createLowlight } from 'lowlight'
|
||||
import '../editor.css'
|
||||
import '../onedark.css'
|
||||
import { SvelteNodeViewRenderer } from 'svelte-tiptap'
|
||||
import CodeExtended from './components/CodeExtended.svelte'
|
||||
import { AudioPlaceholder } from '../extensions/audio/AudioPlaceholder.js'
|
||||
import AudioPlaceholderComponent from './components/AudioPlaceholder.svelte'
|
||||
import AudioExtendedComponent from './components/AudioExtended.svelte'
|
||||
import { ImagePlaceholder } from '../extensions/image/ImagePlaceholder.js'
|
||||
import ImagePlaceholderComponent from './components/ImagePlaceholder.svelte'
|
||||
import { VideoPlaceholder } from '../extensions/video/VideoPlaceholder.js'
|
||||
import VideoPlaceholderComponent from './components/VideoPlaceholder.svelte'
|
||||
import { ImageExtended } from '../extensions/image/ImageExtended.js'
|
||||
import ImageExtendedComponent from './components/ImageExtended.svelte'
|
||||
import VideoExtendedComponent from './components/VideoExtended.svelte'
|
||||
import { VideoExtended } from '../extensions/video/VideoExtended.js'
|
||||
import { AudioExtended } from '../extensions/audio/AudiExtended.js'
|
||||
import LinkMenu from './menus/link-menu.svelte'
|
||||
import TableRowMenu from './menus/table/table-row-menu.svelte'
|
||||
import TableColMenu from './menus/table/table-col-menu.svelte'
|
||||
import slashcommand from '../extensions/slash-command/slashcommand.js'
|
||||
import SlashCommandList from './components/SlashCommandList.svelte'
|
||||
import LoaderCircle from 'lucide-svelte/icons/loader-circle'
|
||||
import { focusEditor, type EdraProps } from '../utils.js'
|
||||
import IFramePlaceholderComponent from './components/IFramePlaceholder.svelte'
|
||||
import { IFramePlaceholder } from '../extensions/iframe/IFramePlaceholder.js'
|
||||
import { IFrameExtended } from '../extensions/iframe/IFrameExtended.js'
|
||||
import IFrameExtendedComponent from './components/IFrameExtended.svelte'
|
||||
|
||||
const lowlight = createLowlight(all);
|
||||
const lowlight = createLowlight(all)
|
||||
|
||||
let {
|
||||
class: className = '',
|
||||
|
|
@ -50,9 +50,9 @@
|
|||
showTableBubbleMenu = true,
|
||||
onUpdate,
|
||||
children
|
||||
}: EdraProps = $props();
|
||||
}: EdraProps = $props()
|
||||
|
||||
let element = $state<HTMLElement>();
|
||||
let element = $state<HTMLElement>()
|
||||
|
||||
onMount(() => {
|
||||
editor = initiateEditor(
|
||||
|
|
@ -64,7 +64,7 @@
|
|||
lowlight
|
||||
}).extend({
|
||||
addNodeView() {
|
||||
return SvelteNodeViewRenderer(CodeExtended);
|
||||
return SvelteNodeViewRenderer(CodeExtended)
|
||||
}
|
||||
}),
|
||||
AudioPlaceholder(AudioPlaceholderComponent),
|
||||
|
|
@ -81,13 +81,13 @@
|
|||
editable,
|
||||
onUpdate,
|
||||
onTransaction: (props) => {
|
||||
editor = undefined;
|
||||
editor = props.editor;
|
||||
editor = undefined
|
||||
editor = props.editor
|
||||
}
|
||||
}
|
||||
);
|
||||
return () => editor?.destroy();
|
||||
});
|
||||
)
|
||||
return () => editor?.destroy()
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class={`edra ${className}`}>
|
||||
|
|
@ -113,7 +113,7 @@
|
|||
onclick={(event) => focusEditor(editor, event)}
|
||||
onkeydown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
focusEditor(editor, event);
|
||||
focusEditor(editor, event)
|
||||
}
|
||||
}}
|
||||
class="edra-editor"
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
export { default as Edra } from './editor.svelte';
|
||||
export { default as EdraToolbar } from './toolbar.svelte';
|
||||
export { default as EdraBubbleMenu } from './menus/bubble-menu.svelte';
|
||||
export { default as Edra } from './editor.svelte'
|
||||
export { default as EdraToolbar } from './toolbar.svelte'
|
||||
export { default as EdraBubbleMenu } from './menus/bubble-menu.svelte'
|
||||
|
|
|
|||
|
|
@ -1,89 +1,89 @@
|
|||
<script lang="ts">
|
||||
import { BubbleMenu } from 'svelte-tiptap';
|
||||
import { isTextSelection, type Editor } from '@tiptap/core';
|
||||
import { commands } from '../../commands/commands.js';
|
||||
import EdraToolBarIcon from '../components/EdraToolBarIcon.svelte';
|
||||
import type { ShouldShowProps } from '../../utils.js';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { BubbleMenu } from 'svelte-tiptap'
|
||||
import { isTextSelection, type Editor } from '@tiptap/core'
|
||||
import { commands } from '../../commands/commands.js'
|
||||
import EdraToolBarIcon from '../components/EdraToolBarIcon.svelte'
|
||||
import type { ShouldShowProps } from '../../utils.js'
|
||||
import type { Snippet } from 'svelte'
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
editor: Editor;
|
||||
children?: Snippet<[]>;
|
||||
class?: string
|
||||
editor: Editor
|
||||
children?: Snippet<[]>
|
||||
}
|
||||
const { class: className = '', editor, children }: Props = $props();
|
||||
const { class: className = '', editor, children }: Props = $props()
|
||||
|
||||
let isDragging = $state(false);
|
||||
let isDragging = $state(false)
|
||||
|
||||
editor.view.dom.addEventListener('dragstart', () => {
|
||||
isDragging = true;
|
||||
});
|
||||
isDragging = true
|
||||
})
|
||||
|
||||
editor.view.dom.addEventListener('drop', () => {
|
||||
isDragging = true;
|
||||
isDragging = true
|
||||
|
||||
// Allow some time for the drop action to complete before re-enabling
|
||||
setTimeout(() => {
|
||||
isDragging = false;
|
||||
}, 100); // Adjust delay if needed
|
||||
});
|
||||
isDragging = false
|
||||
}, 100) // Adjust delay if needed
|
||||
})
|
||||
|
||||
const bubbleMenuCommands = [
|
||||
...commands['text-formatting'].commands,
|
||||
...commands.alignment.commands,
|
||||
...commands.lists.commands
|
||||
];
|
||||
]
|
||||
|
||||
const colorCommands = commands.colors.commands;
|
||||
const fontCommands = commands.fonts.commands;
|
||||
const colorCommands = commands.colors.commands
|
||||
const fontCommands = commands.fonts.commands
|
||||
|
||||
function shouldShow(props: ShouldShowProps) {
|
||||
if (!props.editor.isEditable) return false;
|
||||
const { view, editor } = props;
|
||||
if (!props.editor.isEditable) return false
|
||||
const { view, editor } = props
|
||||
if (!view || editor.view.dragging) {
|
||||
return false;
|
||||
return false
|
||||
}
|
||||
if (editor.isActive('link')) return false;
|
||||
if (editor.isActive('codeBlock')) return false;
|
||||
if (editor.isActive('link')) return false
|
||||
if (editor.isActive('codeBlock')) return false
|
||||
const {
|
||||
state: {
|
||||
doc,
|
||||
selection,
|
||||
selection: { empty, from, to }
|
||||
}
|
||||
} = editor;
|
||||
} = editor
|
||||
// check if the selection is a table grip
|
||||
const domAtPos = view.domAtPos(from || 0).node as HTMLElement;
|
||||
const nodeDOM = view.nodeDOM(from || 0) as HTMLElement;
|
||||
const node = nodeDOM || domAtPos;
|
||||
const domAtPos = view.domAtPos(from || 0).node as HTMLElement
|
||||
const nodeDOM = view.nodeDOM(from || 0) as HTMLElement
|
||||
const node = nodeDOM || domAtPos
|
||||
|
||||
if (isTableGripSelected(node)) {
|
||||
return false;
|
||||
return false
|
||||
}
|
||||
// Sometime check for `empty` is not enough.
|
||||
// Doubleclick an empty paragraph returns a node size of 2.
|
||||
// So we check also for an empty text size.
|
||||
const isEmptyTextBlock = !doc.textBetween(from, to).length && isTextSelection(selection);
|
||||
const isEmptyTextBlock = !doc.textBetween(from, to).length && isTextSelection(selection)
|
||||
if (empty || isEmptyTextBlock || !editor.isEditable) {
|
||||
return false;
|
||||
return false
|
||||
}
|
||||
return !isDragging && !editor.state.selection.empty;
|
||||
return !isDragging && !editor.state.selection.empty
|
||||
}
|
||||
|
||||
const isTableGripSelected = (node: HTMLElement) => {
|
||||
let container = node;
|
||||
let container = node
|
||||
while (container && !['TD', 'TH'].includes(container.tagName)) {
|
||||
container = container.parentElement!;
|
||||
container = container.parentElement!
|
||||
}
|
||||
const gripColumn =
|
||||
container && container.querySelector && container.querySelector('a.grip-column.selected');
|
||||
container && container.querySelector && container.querySelector('a.grip-column.selected')
|
||||
const gripRow =
|
||||
container && container.querySelector && container.querySelector('a.grip-row.selected');
|
||||
container && container.querySelector && container.querySelector('a.grip-row.selected')
|
||||
if (gripColumn || gripRow) {
|
||||
return true;
|
||||
return true
|
||||
}
|
||||
return false;
|
||||
};
|
||||
return false
|
||||
}
|
||||
</script>
|
||||
|
||||
<BubbleMenu
|
||||
|
|
@ -130,14 +130,14 @@
|
|||
{editor}
|
||||
style={`color: ${editor.getAttributes('textStyle').color};`}
|
||||
onclick={() => {
|
||||
const color = editor.getAttributes('textStyle').color;
|
||||
const hasColor = editor.isActive('textStyle', { color });
|
||||
const color = editor.getAttributes('textStyle').color
|
||||
const hasColor = editor.isActive('textStyle', { color })
|
||||
if (hasColor) {
|
||||
editor.chain().focus().unsetColor().run();
|
||||
editor.chain().focus().unsetColor().run()
|
||||
} else {
|
||||
const color = prompt('Enter the color of the text:');
|
||||
const color = prompt('Enter the color of the text:')
|
||||
if (color !== null) {
|
||||
editor.chain().focus().setColor(color).run();
|
||||
editor.chain().focus().setColor(color).run()
|
||||
}
|
||||
}
|
||||
}}
|
||||
|
|
@ -147,13 +147,13 @@
|
|||
{editor}
|
||||
style={`background-color: ${editor.getAttributes('highlight').color};`}
|
||||
onclick={() => {
|
||||
const hasHightlight = editor.isActive('highlight');
|
||||
const hasHightlight = editor.isActive('highlight')
|
||||
if (hasHightlight) {
|
||||
editor.chain().focus().unsetHighlight().run();
|
||||
editor.chain().focus().unsetHighlight().run()
|
||||
} else {
|
||||
const color = prompt('Enter the color of the highlight:');
|
||||
const color = prompt('Enter the color of the highlight:')
|
||||
if (color !== null) {
|
||||
editor.chain().focus().setHighlight({ color }).run();
|
||||
editor.chain().focus().setHighlight({ color }).run()
|
||||
}
|
||||
}
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -1,36 +1,36 @@
|
|||
<script lang="ts">
|
||||
import { type Editor } from '@tiptap/core';
|
||||
import { BubbleMenu } from 'svelte-tiptap';
|
||||
import type { ShouldShowProps } from '../../utils.js';
|
||||
import Copy from 'lucide-svelte/icons/copy';
|
||||
import Trash from 'lucide-svelte/icons/trash';
|
||||
import Edit from 'lucide-svelte/icons/pen';
|
||||
import Check from 'lucide-svelte/icons/check';
|
||||
import { type Editor } from '@tiptap/core'
|
||||
import { BubbleMenu } from 'svelte-tiptap'
|
||||
import type { ShouldShowProps } from '../../utils.js'
|
||||
import Copy from 'lucide-svelte/icons/copy'
|
||||
import Trash from 'lucide-svelte/icons/trash'
|
||||
import Edit from 'lucide-svelte/icons/pen'
|
||||
import Check from 'lucide-svelte/icons/check'
|
||||
|
||||
interface Props {
|
||||
editor: Editor;
|
||||
editor: Editor
|
||||
}
|
||||
|
||||
let { editor }: Props = $props();
|
||||
let { editor }: Props = $props()
|
||||
|
||||
const link = $derived.by(() => editor.getAttributes('link').href);
|
||||
const link = $derived.by(() => editor.getAttributes('link').href)
|
||||
|
||||
let isEditing = $state(false);
|
||||
let isEditing = $state(false)
|
||||
|
||||
function setLink(url: string) {
|
||||
if (url.trim() === '') {
|
||||
editor.chain().focus().extendMarkRange('link').unsetLink().run();
|
||||
return;
|
||||
editor.chain().focus().extendMarkRange('link').unsetLink().run()
|
||||
return
|
||||
}
|
||||
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
|
||||
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run()
|
||||
}
|
||||
|
||||
let linkInput = $state('');
|
||||
let isLinkValid = $state(true);
|
||||
let linkInput = $state('')
|
||||
let isLinkValid = $state(true)
|
||||
|
||||
$effect(() => {
|
||||
isLinkValid = validateURL(linkInput);
|
||||
});
|
||||
isLinkValid = validateURL(linkInput)
|
||||
})
|
||||
|
||||
function validateURL(url: string): boolean {
|
||||
const urlPattern = new RegExp(
|
||||
|
|
@ -41,8 +41,8 @@
|
|||
'(\\?[;&a-zA-Z\\d%_.~+=-]*)?' + // query string
|
||||
'(\\#[-a-zA-Z\\d_]*)?$', // fragment locator
|
||||
'i'
|
||||
);
|
||||
return urlPattern.test(url);
|
||||
)
|
||||
return urlPattern.test(url)
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
@ -50,14 +50,14 @@
|
|||
{editor}
|
||||
pluginKey="link-menu"
|
||||
shouldShow={(props: ShouldShowProps) => {
|
||||
if (!props.editor.isEditable) return false;
|
||||
if (!props.editor.isEditable) return false
|
||||
if (props.editor.isActive('link')) {
|
||||
return true;
|
||||
return true
|
||||
} else {
|
||||
isEditing = false;
|
||||
linkInput = '';
|
||||
isLinkValid = true;
|
||||
return false;
|
||||
isEditing = false
|
||||
linkInput = ''
|
||||
isLinkValid = true
|
||||
return false
|
||||
}
|
||||
}}
|
||||
class="bubble-menu-wrapper"
|
||||
|
|
@ -79,8 +79,8 @@
|
|||
<button
|
||||
class="edra-command-button"
|
||||
onclick={() => {
|
||||
linkInput = link;
|
||||
isEditing = true;
|
||||
linkInput = link
|
||||
isEditing = true
|
||||
}}
|
||||
title="Edit the URL"
|
||||
>
|
||||
|
|
@ -89,7 +89,7 @@
|
|||
<button
|
||||
class="edra-command-button"
|
||||
onclick={() => {
|
||||
navigator.clipboard.writeText(link);
|
||||
navigator.clipboard.writeText(link)
|
||||
}}
|
||||
title="Copy the URL to the clipboard"
|
||||
>
|
||||
|
|
@ -98,7 +98,7 @@
|
|||
<button
|
||||
class="edra-command-button"
|
||||
onclick={() => {
|
||||
editor.chain().focus().extendMarkRange('link').unsetLink().run();
|
||||
editor.chain().focus().extendMarkRange('link').unsetLink().run()
|
||||
}}
|
||||
title="Remove the link"
|
||||
>
|
||||
|
|
@ -108,9 +108,9 @@
|
|||
<button
|
||||
class="edra-command-button"
|
||||
onclick={() => {
|
||||
isEditing = false;
|
||||
editor.commands.focus();
|
||||
setLink(linkInput);
|
||||
isEditing = false
|
||||
editor.commands.focus()
|
||||
setLink(linkInput)
|
||||
}}
|
||||
disabled={!isLinkValid}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,32 +1,32 @@
|
|||
<script lang="ts">
|
||||
import type { ShouldShowProps } from '../../../utils.js';
|
||||
import { type Editor } from '@tiptap/core';
|
||||
import { BubbleMenu } from 'svelte-tiptap';
|
||||
import ArrowLeftFromLine from 'lucide-svelte/icons/arrow-left-from-line';
|
||||
import ArrowRightFromLine from 'lucide-svelte/icons/arrow-right-from-line';
|
||||
import Trash from 'lucide-svelte/icons/trash';
|
||||
import { isColumnGripSelected } from '../../../extensions/table/utils.js';
|
||||
import type { ShouldShowProps } from '../../../utils.js'
|
||||
import { type Editor } from '@tiptap/core'
|
||||
import { BubbleMenu } from 'svelte-tiptap'
|
||||
import ArrowLeftFromLine from 'lucide-svelte/icons/arrow-left-from-line'
|
||||
import ArrowRightFromLine from 'lucide-svelte/icons/arrow-right-from-line'
|
||||
import Trash from 'lucide-svelte/icons/trash'
|
||||
import { isColumnGripSelected } from '../../../extensions/table/utils.js'
|
||||
interface Props {
|
||||
editor: Editor;
|
||||
editor: Editor
|
||||
}
|
||||
|
||||
let { editor }: Props = $props();
|
||||
let { editor }: Props = $props()
|
||||
</script>
|
||||
|
||||
<BubbleMenu
|
||||
{editor}
|
||||
pluginKey="table-col-menu"
|
||||
shouldShow={(props: ShouldShowProps) => {
|
||||
if (!props.editor.isEditable) return false;
|
||||
if (!props.editor.isEditable) return false
|
||||
if (!props.state) {
|
||||
return false;
|
||||
return false
|
||||
}
|
||||
return isColumnGripSelected({
|
||||
editor: props.editor,
|
||||
view: props.view,
|
||||
state: props.state,
|
||||
from: props.from
|
||||
});
|
||||
})
|
||||
}}
|
||||
class="edra-menu-wrapper"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,32 +1,32 @@
|
|||
<script lang="ts">
|
||||
import type { ShouldShowProps } from '../../../utils.js';
|
||||
import { type Editor } from '@tiptap/core';
|
||||
import { BubbleMenu } from 'svelte-tiptap';
|
||||
import ArrowDownFromLine from 'lucide-svelte/icons/arrow-down-from-line';
|
||||
import ArrowUpFromLine from 'lucide-svelte/icons/arrow-up-from-line';
|
||||
import Trash from 'lucide-svelte/icons/trash';
|
||||
import { isRowGripSelected } from '../../../extensions/table/utils.js';
|
||||
import type { ShouldShowProps } from '../../../utils.js'
|
||||
import { type Editor } from '@tiptap/core'
|
||||
import { BubbleMenu } from 'svelte-tiptap'
|
||||
import ArrowDownFromLine from 'lucide-svelte/icons/arrow-down-from-line'
|
||||
import ArrowUpFromLine from 'lucide-svelte/icons/arrow-up-from-line'
|
||||
import Trash from 'lucide-svelte/icons/trash'
|
||||
import { isRowGripSelected } from '../../../extensions/table/utils.js'
|
||||
interface Props {
|
||||
editor: Editor;
|
||||
editor: Editor
|
||||
}
|
||||
|
||||
let { editor }: Props = $props();
|
||||
let { editor }: Props = $props()
|
||||
</script>
|
||||
|
||||
<BubbleMenu
|
||||
{editor}
|
||||
pluginKey="table-row-menu"
|
||||
shouldShow={(props: ShouldShowProps) => {
|
||||
if (!props.editor.isEditable) return false;
|
||||
if (!props.editor.isEditable) return false
|
||||
if (!props.state) {
|
||||
return false;
|
||||
return false
|
||||
}
|
||||
return isRowGripSelected({
|
||||
editor: props.editor,
|
||||
view: props.view,
|
||||
state: props.state,
|
||||
from: props.from
|
||||
});
|
||||
})
|
||||
}}
|
||||
class="edra-menu-wrapper"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,23 +1,23 @@
|
|||
<script lang="ts">
|
||||
import type { Editor } from '@tiptap/core';
|
||||
import { commands } from '../commands/commands.js';
|
||||
import EdraToolBarIcon from './components/EdraToolBarIcon.svelte';
|
||||
import SearchAndReplace from './components/SearchAndReplace.svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { Editor } from '@tiptap/core'
|
||||
import { commands } from '../commands/commands.js'
|
||||
import EdraToolBarIcon from './components/EdraToolBarIcon.svelte'
|
||||
import SearchAndReplace from './components/SearchAndReplace.svelte'
|
||||
import type { Snippet } from 'svelte'
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
editor: Editor;
|
||||
children?: Snippet<[]>;
|
||||
class?: string
|
||||
editor: Editor
|
||||
children?: Snippet<[]>
|
||||
}
|
||||
|
||||
const { class: className = '', editor, children }: Props = $props();
|
||||
const { class: className = '', editor, children }: Props = $props()
|
||||
|
||||
// Special components that are handled separately
|
||||
let showSearchAndReplace = $state(false);
|
||||
const colorCommands = commands.colors.commands;
|
||||
const fontCommands = commands.fonts.commands;
|
||||
const excludedCommands = ['colors', 'fonts'];
|
||||
let showSearchAndReplace = $state(false)
|
||||
const colorCommands = commands.colors.commands
|
||||
const fontCommands = commands.fonts.commands
|
||||
const excludedCommands = ['colors', 'fonts']
|
||||
</script>
|
||||
|
||||
<div class={`edra-toolbar ${className}`}>
|
||||
|
|
@ -44,14 +44,14 @@
|
|||
{editor}
|
||||
style={`color: ${editor.getAttributes('textStyle').color};`}
|
||||
onclick={() => {
|
||||
const color = editor.getAttributes('textStyle').color;
|
||||
const hasColor = editor.isActive('textStyle', { color });
|
||||
const color = editor.getAttributes('textStyle').color
|
||||
const hasColor = editor.isActive('textStyle', { color })
|
||||
if (hasColor) {
|
||||
editor.chain().focus().unsetColor().run();
|
||||
editor.chain().focus().unsetColor().run()
|
||||
} else {
|
||||
const color = prompt('Enter the color of the text:');
|
||||
const color = prompt('Enter the color of the text:')
|
||||
if (color !== null) {
|
||||
editor.chain().focus().setColor(color).run();
|
||||
editor.chain().focus().setColor(color).run()
|
||||
}
|
||||
}
|
||||
}}
|
||||
|
|
@ -61,13 +61,13 @@
|
|||
{editor}
|
||||
style={`background-color: ${editor.getAttributes('highlight').color};`}
|
||||
onclick={() => {
|
||||
const hasHightlight = editor.isActive('highlight');
|
||||
const hasHightlight = editor.isActive('highlight')
|
||||
if (hasHightlight) {
|
||||
editor.chain().focus().unsetHighlight().run();
|
||||
editor.chain().focus().unsetHighlight().run()
|
||||
} else {
|
||||
const color = prompt('Enter the color of the highlight:');
|
||||
const color = prompt('Enter the color of the highlight:')
|
||||
if (color !== null) {
|
||||
editor.chain().focus().setHighlight({ color }).run();
|
||||
editor.chain().focus().setHighlight({ color }).run()
|
||||
}
|
||||
}
|
||||
}}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue