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)
|
# Local uploads (for development)
|
||||||
/static/local-uploads
|
/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.
|
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
|
### Key Architecture Components
|
||||||
|
|
||||||
**API Integration Layer** (`src/routes/api/`)
|
**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 js from '@eslint/js'
|
||||||
import ts from 'typescript-eslint'
|
import ts from 'typescript-eslint'
|
||||||
import svelte from 'eslint-plugin-svelte'
|
import svelte from 'eslint-plugin-svelte'
|
||||||
|
|
@ -29,5 +32,6 @@ export default [
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ignores: ['build/', '.svelte-kit/', 'dist/']
|
ignores: ['build/', '.svelte-kit/', 'dist/']
|
||||||
}
|
},
|
||||||
]
|
...storybook.configs["flat/recommended"]
|
||||||
|
];
|
||||||
|
|
|
||||||
1272
package-lock.json
generated
1272
package-lock.json
generated
File diff suppressed because it is too large
Load diff
10
package.json
10
package.json
|
|
@ -14,11 +14,17 @@
|
||||||
"db:migrate": "prisma migrate dev",
|
"db:migrate": "prisma migrate dev",
|
||||||
"db:seed": "prisma db seed",
|
"db:seed": "prisma db seed",
|
||||||
"db:studio": "prisma studio",
|
"db:studio": "prisma studio",
|
||||||
"setup:local": "./scripts/setup-local.sh"
|
"setup:local": "./scripts/setup-local.sh",
|
||||||
|
"storybook": "storybook dev -p 6006",
|
||||||
|
"build-storybook": "storybook build"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@musicorum/lastfm": "github:jedmund/lastfm",
|
"@musicorum/lastfm": "github:jedmund/lastfm",
|
||||||
"@poppanator/sveltekit-svg": "^5.0.0-svelte5.4",
|
"@poppanator/sveltekit-svg": "^5.0.0-svelte5.4",
|
||||||
|
"@storybook/addon-a11y": "^9.0.1",
|
||||||
|
"@storybook/addon-docs": "^9.0.1",
|
||||||
|
"@storybook/addon-svelte-csf": "^5.0.3",
|
||||||
|
"@storybook/sveltekit": "^9.0.1",
|
||||||
"@sveltejs/adapter-auto": "^3.0.0",
|
"@sveltejs/adapter-auto": "^3.0.0",
|
||||||
"@sveltejs/kit": "^2.0.0",
|
"@sveltejs/kit": "^2.0.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^4.0.0-next.6",
|
"@sveltejs/vite-plugin-svelte": "^4.0.0-next.6",
|
||||||
|
|
@ -27,12 +33,14 @@
|
||||||
"autoprefixer": "^10.4.19",
|
"autoprefixer": "^10.4.19",
|
||||||
"eslint": "^9.0.0",
|
"eslint": "^9.0.0",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
|
"eslint-plugin-storybook": "^9.0.1",
|
||||||
"eslint-plugin-svelte": "^2.36.0",
|
"eslint-plugin-svelte": "^2.36.0",
|
||||||
"globals": "^15.0.0",
|
"globals": "^15.0.0",
|
||||||
"postcss": "^8.4.39",
|
"postcss": "^8.4.39",
|
||||||
"prettier": "^3.1.1",
|
"prettier": "^3.1.1",
|
||||||
"prettier-plugin-svelte": "^3.1.2",
|
"prettier-plugin-svelte": "^3.1.2",
|
||||||
"sass": "^1.77.8",
|
"sass": "^1.77.8",
|
||||||
|
"storybook": "^9.0.1",
|
||||||
"svelte": "^5.0.0-next.1",
|
"svelte": "^5.0.0-next.1",
|
||||||
"svelte-check": "^3.6.0",
|
"svelte-check": "^3.6.0",
|
||||||
"tslib": "^2.4.1",
|
"tslib": "^2.4.1",
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
model Media {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
filename String @db.VarChar(255)
|
filename String @db.VarChar(255)
|
||||||
|
originalName String? @db.VarChar(255) // Original filename from user (optional for backward compatibility)
|
||||||
mimeType String @db.VarChar(100)
|
mimeType String @db.VarChar(100)
|
||||||
size Int
|
size Int
|
||||||
url String @db.Text
|
url String @db.Text
|
||||||
thumbnailUrl String? @db.Text
|
thumbnailUrl String? @db.Text
|
||||||
width Int?
|
width Int?
|
||||||
height Int?
|
height Int?
|
||||||
|
altText String? @db.Text // Alt text for accessibility
|
||||||
|
description String? @db.Text // Optional description
|
||||||
usedIn Json @default("[]") // Track where media is used
|
usedIn Json @default("[]") // Track where media is used
|
||||||
createdAt DateTime @default(now())
|
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-90: #f7f7f7;
|
||||||
$grey-85: #ebebeb;
|
$grey-85: #ebebeb;
|
||||||
$grey-80: #e8e8e8;
|
$grey-80: #e8e8e8;
|
||||||
|
$grey-70: #dfdfdf;
|
||||||
$grey-60: #cccccc;
|
$grey-60: #cccccc;
|
||||||
|
$grey-5: #f9f9f9;
|
||||||
$grey-50: #b2b2b2;
|
$grey-50: #b2b2b2;
|
||||||
$grey-40: #999999;
|
$grey-40: #999999;
|
||||||
$grey-30: #808080;
|
$grey-30: #808080;
|
||||||
|
|
@ -79,9 +81,26 @@ $grey-00: #333333;
|
||||||
|
|
||||||
$red-80: #ff6a54;
|
$red-80: #ff6a54;
|
||||||
$red-60: #e33d3d;
|
$red-60: #e33d3d;
|
||||||
|
$red-50: #d33;
|
||||||
$red-40: #d31919;
|
$red-40: #d31919;
|
||||||
$red-00: #3d0c0c;
|
$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
|
$salmon-pink: #ffbdb3; // Desaturated salmon pink for hover states
|
||||||
|
|
||||||
$bg-color: #e8e8e8;
|
$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>
|
<script>
|
||||||
// What if we have a headphones avatar that is head bopping if the last scrobble was < 5 mins ago
|
// 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
|
// 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'
|
import { spring } from 'svelte/motion'
|
||||||
|
|
||||||
let isHovering = $state(false)
|
let isHovering = false
|
||||||
let isBlinking = $state(false)
|
let isBlinking = false
|
||||||
|
|
||||||
const scale = spring(1, {
|
const scale = spring(1, {
|
||||||
stiffness: 0.1,
|
stiffness: 0.1,
|
||||||
damping: 0.125
|
damping: 0.125
|
||||||
})
|
})
|
||||||
|
|
||||||
$effect(() => {
|
function handleMouseEnter() {
|
||||||
if (isHovering) {
|
isHovering = true
|
||||||
scale.set(1.25)
|
scale.set(1.25)
|
||||||
} else {
|
}
|
||||||
|
|
||||||
|
function handleMouseLeave() {
|
||||||
|
isHovering = false
|
||||||
scale.set(1)
|
scale.set(1)
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
|
||||||
function sleep(ms) {
|
function sleep(ms) {
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||||
}
|
}
|
||||||
|
|
||||||
function setBlinkState(state) {
|
|
||||||
isBlinking = state
|
|
||||||
}
|
|
||||||
|
|
||||||
async function singleBlink(duration) {
|
async function singleBlink(duration) {
|
||||||
setBlinkState(true)
|
isBlinking = true
|
||||||
await sleep(duration)
|
await sleep(duration)
|
||||||
setBlinkState(false)
|
isBlinking = false
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doubleBlink() {
|
async function doubleBlink() {
|
||||||
|
|
@ -48,25 +46,27 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function startBlinking() {
|
let blinkInterval
|
||||||
const blinkInterval = setInterval(() => {
|
|
||||||
|
onMount(() => {
|
||||||
|
blinkInterval = setInterval(() => {
|
||||||
if (!isHovering) {
|
if (!isHovering) {
|
||||||
blink()
|
blink()
|
||||||
}
|
}
|
||||||
}, 4000)
|
}, 4000)
|
||||||
|
|
||||||
return () => clearInterval(blinkInterval)
|
return () => {
|
||||||
|
if (blinkInterval) {
|
||||||
|
clearInterval(blinkInterval)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
return startBlinking()
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="face-container"
|
class="face-container"
|
||||||
on:mouseenter={() => (isHovering = true)}
|
onmouseenter={handleMouseEnter}
|
||||||
on:mouseleave={() => (isHovering = false)}
|
onmouseleave={handleMouseLeave}
|
||||||
style="transform: scale({$scale})"
|
style="transform: scale({$scale})"
|
||||||
>
|
>
|
||||||
<svg
|
<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%;
|
width: 100%;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-family: 'cstd', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
|
||||||
transition: border-color 0.2s ease;
|
transition: border-color 0.2s ease;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
|
|
@ -163,7 +162,6 @@
|
||||||
gap: $unit;
|
gap: $unit;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: $grey-40;
|
color: $grey-40;
|
||||||
font-family: 'cstd', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.favicon {
|
.favicon {
|
||||||
|
|
@ -184,7 +182,6 @@
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: $grey-00;
|
color: $grey-00;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
font-family: 'cstd', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
-webkit-line-clamp: 2;
|
-webkit-line-clamp: 2;
|
||||||
|
|
@ -196,7 +193,6 @@
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: $grey-40;
|
color: $grey-40;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
font-family: 'cstd', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
-webkit-line-clamp: 3;
|
-webkit-line-clamp: 3;
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,6 @@
|
||||||
color: $grey-20; // #666
|
color: $grey-20; // #666
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-family: 'cstd', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
:global(svg) {
|
:global(svg) {
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,15 @@
|
||||||
index?: number
|
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)
|
const isEven = $derived(index % 2 === 0)
|
||||||
|
|
||||||
|
|
@ -161,11 +169,7 @@
|
||||||
>
|
>
|
||||||
<div class="project-logo" style="background-color: {backgroundColor}">
|
<div class="project-logo" style="background-color: {backgroundColor}">
|
||||||
{#if svgContent}
|
{#if svgContent}
|
||||||
<div
|
<div bind:this={logoElement} class="logo-svg" style="transform: {logoTransform}">
|
||||||
bind:this={logoElement}
|
|
||||||
class="logo-svg"
|
|
||||||
style="transform: {logoTransform}"
|
|
||||||
>
|
|
||||||
{@html svgContent}
|
{@html svgContent}
|
||||||
</div>
|
</div>
|
||||||
{:else if logoUrl}
|
{:else if logoUrl}
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,5 @@
|
||||||
padding: $unit-3x;
|
padding: $unit-3x;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: $grey-40;
|
color: $grey-40;
|
||||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
@ -162,7 +162,6 @@
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-family: 'cstd', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
transition:
|
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">
|
<script lang="ts">
|
||||||
import { page } from '$app/stores'
|
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)
|
const currentPath = $derived($page.url.pathname)
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
text: string
|
text: string
|
||||||
href: string
|
href: string
|
||||||
icon: string
|
icon: any
|
||||||
}
|
}
|
||||||
|
|
||||||
const navItems: NavItem[] = [
|
const navItems: NavItem[] = [
|
||||||
{ text: 'Dashboard', href: '/admin', icon: 'dashboard' },
|
{ text: 'Dashboard', href: '/admin', icon: DashboardIcon },
|
||||||
{ text: 'Projects', href: '/admin/projects', icon: 'work' },
|
{ text: 'Projects', href: '/admin/projects', icon: WorkIcon },
|
||||||
{ text: 'Universe', href: '/admin/posts', icon: 'universe' },
|
{ text: 'Universe', href: '/admin/posts', icon: UniverseIcon },
|
||||||
{ text: 'Media', href: '/admin/media', icon: 'photos' }
|
{ text: 'Media', href: '/admin/media', icon: PhotosIcon }
|
||||||
]
|
]
|
||||||
|
|
||||||
// Calculate active index based on current path
|
// Calculate active index based on current path
|
||||||
|
|
@ -36,7 +40,7 @@
|
||||||
<div class="nav-content">
|
<div class="nav-content">
|
||||||
<a href="/" class="nav-brand">
|
<a href="/" class="nav-brand">
|
||||||
<div class="brand-logo">
|
<div class="brand-logo">
|
||||||
<Avatar />
|
<AvatarSimple />
|
||||||
</div>
|
</div>
|
||||||
<span class="brand-text">Back to jedmund.com</span>
|
<span class="brand-text">Back to jedmund.com</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
@ -44,108 +48,7 @@
|
||||||
<div class="nav-links">
|
<div class="nav-links">
|
||||||
{#each navItems as item, index}
|
{#each navItems as item, index}
|
||||||
<a href={item.href} class="nav-link" class:active={index === activeIndex}>
|
<a href={item.href} class="nav-link" class:active={index === activeIndex}>
|
||||||
<svg class="nav-icon" width="20" height="20" viewBox="0 0 20 20">
|
<item.icon class="nav-icon" />
|
||||||
{#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>
|
|
||||||
<span class="nav-text">{item.text}</span>
|
<span class="nav-text">{item.text}</span>
|
||||||
</a>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
@ -168,8 +71,8 @@
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background-color: $grey-60;
|
background: $grey-90;
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
border-bottom: 1px solid $grey-70;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-container {
|
.nav-container {
|
||||||
|
|
@ -222,7 +125,6 @@
|
||||||
gap: $unit;
|
gap: $unit;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: $grey-30;
|
color: $grey-30;
|
||||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-size: 0.925rem;
|
font-size: 0.925rem;
|
||||||
transition: color 0.2s ease;
|
transition: color 0.2s ease;
|
||||||
|
|
@ -281,7 +183,6 @@
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-size: 0.925rem;
|
font-size: 0.925rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
|
||||||
color: $grey-30;
|
color: $grey-30;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,6 @@
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 50px;
|
border-radius: 50px;
|
||||||
font-size: 0.925rem;
|
font-size: 0.925rem;
|
||||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
|
||||||
color: $grey-40;
|
color: $grey-40;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
|
|
|
||||||
|
|
@ -175,7 +175,6 @@
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-family: 'cstd', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
|
||||||
color: $grey-20;
|
color: $grey-20;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
|
|
@ -251,7 +250,6 @@
|
||||||
border: none;
|
border: none;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
font-size: 0.925rem;
|
font-size: 0.925rem;
|
||||||
font-family: 'cstd', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
|
||||||
color: $grey-20;
|
color: $grey-20;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background-color 0.2s ease;
|
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">
|
<script lang="ts">
|
||||||
import { createEventDispatcher } from 'svelte'
|
import { createEventDispatcher } from 'svelte'
|
||||||
|
import Button from './Button.svelte'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title?: string
|
title?: string
|
||||||
|
|
@ -38,12 +39,12 @@
|
||||||
<h2>{title}</h2>
|
<h2>{title}</h2>
|
||||||
<p>{message}</p>
|
<p>{message}</p>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button class="btn btn-secondary" onclick={handleCancel}>
|
<Button variant="secondary" onclick={handleCancel}>
|
||||||
{cancelText}
|
{cancelText}
|
||||||
</button>
|
</Button>
|
||||||
<button class="btn btn-danger" onclick={handleConfirm}>
|
<Button variant="danger" onclick={handleConfirm}>
|
||||||
{confirmText}
|
{confirmText}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -75,14 +76,12 @@
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: $grey-10;
|
color: $grey-10;
|
||||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
margin: 0 0 $unit-4x;
|
margin: 0 0 $unit-4x;
|
||||||
color: $grey-20;
|
color: $grey-20;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -91,33 +90,4 @@
|
||||||
gap: $unit-2x;
|
gap: $unit-2x;
|
||||||
justify-content: flex-end;
|
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
|
autofocus?: boolean
|
||||||
class?: string
|
class?: string
|
||||||
showToolbar?: boolean
|
showToolbar?: boolean
|
||||||
|
simpleMode?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
|
|
@ -25,7 +26,8 @@
|
||||||
minHeight = 400,
|
minHeight = 400,
|
||||||
autofocus = false,
|
autofocus = false,
|
||||||
class: className = '',
|
class: className = '',
|
||||||
showToolbar = true
|
showToolbar = true,
|
||||||
|
simpleMode = false
|
||||||
}: Props = $props()
|
}: Props = $props()
|
||||||
|
|
||||||
let editor = $state<Editor | undefined>()
|
let editor = $state<Editor | undefined>()
|
||||||
|
|
@ -65,7 +67,12 @@
|
||||||
// Focus on mount if requested
|
// Focus on mount if requested
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (editor && autofocus) {
|
if (editor && autofocus) {
|
||||||
|
// Only focus once on initial mount
|
||||||
|
const timer = setTimeout(() => {
|
||||||
editor.commands.focus()
|
editor.commands.focus()
|
||||||
|
}, 100)
|
||||||
|
|
||||||
|
return () => clearTimeout(timer)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -77,11 +84,11 @@
|
||||||
content={data}
|
content={data}
|
||||||
{onUpdate}
|
{onUpdate}
|
||||||
editable={!readOnly}
|
editable={!readOnly}
|
||||||
{showToolbar}
|
showToolbar={!simpleMode && showToolbar}
|
||||||
{placeholder}
|
{placeholder}
|
||||||
showSlashCommands={true}
|
showSlashCommands={!simpleMode}
|
||||||
showLinkBubbleMenu={true}
|
showLinkBubbleMenu={!simpleMode}
|
||||||
showTableBubbleMenu={true}
|
showTableBubbleMenu={false}
|
||||||
class="editor-content"
|
class="editor-content"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -171,7 +178,6 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.edra .ProseMirror) {
|
:global(.edra .ProseMirror) {
|
||||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
color: $grey-10;
|
color: $grey-10;
|
||||||
|
|
@ -180,7 +186,6 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.edra .ProseMirror h1) {
|
:global(.edra .ProseMirror h1) {
|
||||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
|
||||||
font-size: 2rem;
|
font-size: 2rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
margin: $unit-3x 0 $unit-2x;
|
margin: $unit-3x 0 $unit-2x;
|
||||||
|
|
@ -188,7 +193,6 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.edra .ProseMirror h2) {
|
:global(.edra .ProseMirror h2) {
|
||||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin: $unit-3x 0 $unit-2x;
|
margin: $unit-3x 0 $unit-2x;
|
||||||
|
|
@ -196,7 +200,6 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.edra .ProseMirror h3) {
|
:global(.edra .ProseMirror h3) {
|
||||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin: $unit-3x 0 $unit-2x;
|
margin: $unit-3x 0 $unit-2x;
|
||||||
|
|
@ -335,7 +338,6 @@
|
||||||
:global(.edra-media-placeholder-text) {
|
:global(.edra-media-placeholder-text) {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
color: $grey-30;
|
color: $grey-30;
|
||||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Image container styles
|
// Image container styles
|
||||||
|
|
@ -372,7 +374,6 @@
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: $grey-30;
|
color: $grey-30;
|
||||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
|
||||||
background: $grey-95;
|
background: $grey-95;
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
|
|
|
||||||
|
|
@ -86,15 +86,15 @@
|
||||||
|
|
||||||
// Group basic formatting first
|
// Group basic formatting first
|
||||||
const basicOrder = ['bold', 'italic', 'underline', 'strike']
|
const basicOrder = ['bold', 'italic', 'underline', 'strike']
|
||||||
basicOrder.forEach(name => {
|
basicOrder.forEach((name) => {
|
||||||
const cmd = allCommands.find(c => c.name === name)
|
const cmd = allCommands.find((c) => c.name === name)
|
||||||
if (cmd) basicFormatting.push(cmd)
|
if (cmd) basicFormatting.push(cmd)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Then link and code
|
// Then link and code
|
||||||
const advancedOrder = ['link', 'code']
|
const advancedOrder = ['link', 'code']
|
||||||
advancedOrder.forEach(name => {
|
advancedOrder.forEach((name) => {
|
||||||
const cmd = allCommands.find(c => c.name === name)
|
const cmd = allCommands.find((c) => c.name === name)
|
||||||
if (cmd) advancedFormatting.push(cmd)
|
if (cmd) advancedFormatting.push(cmd)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -121,7 +121,7 @@
|
||||||
// Get media commands, but filter out iframe
|
// Get media commands, but filter out iframe
|
||||||
const getMediaCommands = () => {
|
const getMediaCommands = () => {
|
||||||
if (commands.media) {
|
if (commands.media) {
|
||||||
return commands.media.commands.filter(cmd => cmd.name !== 'iframe-placeholder')
|
return commands.media.commands.filter((cmd) => cmd.name !== 'iframe-placeholder')
|
||||||
}
|
}
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
@ -341,14 +341,22 @@
|
||||||
<div class="edra-toolbar">
|
<div class="edra-toolbar">
|
||||||
<!-- Text Style Dropdown -->
|
<!-- Text Style Dropdown -->
|
||||||
<div class="text-style-dropdown">
|
<div class="text-style-dropdown">
|
||||||
<button
|
<button bind:this={dropdownTriggerRef} class="dropdown-trigger" onclick={toggleDropdown}>
|
||||||
bind:this={dropdownTriggerRef}
|
|
||||||
class="dropdown-trigger"
|
|
||||||
onclick={toggleDropdown}
|
|
||||||
>
|
|
||||||
<span>{getCurrentTextStyle(editor)}</span>
|
<span>{getCurrentTextStyle(editor)}</span>
|
||||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg
|
||||||
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
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>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -371,8 +379,20 @@
|
||||||
onclick={toggleMediaDropdown}
|
onclick={toggleMediaDropdown}
|
||||||
>
|
>
|
||||||
<span>Insert</span>
|
<span>Insert</span>
|
||||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg
|
||||||
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
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>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -384,14 +404,14 @@
|
||||||
{editor}
|
{editor}
|
||||||
style={`color: ${editor.getAttributes('textStyle').color};`}
|
style={`color: ${editor.getAttributes('textStyle').color};`}
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
const color = editor.getAttributes('textStyle').color;
|
const color = editor.getAttributes('textStyle').color
|
||||||
const hasColor = editor.isActive('textStyle', { color });
|
const hasColor = editor.isActive('textStyle', { color })
|
||||||
if (hasColor) {
|
if (hasColor) {
|
||||||
editor.chain().focus().unsetColor().run();
|
editor.chain().focus().unsetColor().run()
|
||||||
} else {
|
} else {
|
||||||
const color = prompt('Enter the color of the text:');
|
const color = prompt('Enter the color of the text:')
|
||||||
if (color !== null) {
|
if (color !== null) {
|
||||||
editor.chain().focus().setColor(color).run();
|
editor.chain().focus().setColor(color).run()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|
@ -401,13 +421,13 @@
|
||||||
{editor}
|
{editor}
|
||||||
style={`background-color: ${editor.getAttributes('highlight').color};`}
|
style={`background-color: ${editor.getAttributes('highlight').color};`}
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
const hasHightlight = editor.isActive('highlight');
|
const hasHightlight = editor.isActive('highlight')
|
||||||
if (hasHightlight) {
|
if (hasHightlight) {
|
||||||
editor.chain().focus().unsetHighlight().run();
|
editor.chain().focus().unsetHighlight().run()
|
||||||
} else {
|
} else {
|
||||||
const color = prompt('Enter the color of the highlight:');
|
const color = prompt('Enter the color of the highlight:')
|
||||||
if (color !== null) {
|
if (color !== null) {
|
||||||
editor.chain().focus().setHighlight({ color }).run();
|
editor.chain().focus().setHighlight({ color }).run()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|
@ -450,33 +470,72 @@
|
||||||
style="position: fixed; top: {mediaDropdownPosition.top}px; left: {mediaDropdownPosition.left}px; z-index: 10000;"
|
style="position: fixed; top: {mediaDropdownPosition.top}px; left: {mediaDropdownPosition.left}px; z-index: 10000;"
|
||||||
>
|
>
|
||||||
<div class="dropdown-menu">
|
<div class="dropdown-menu">
|
||||||
<button class="dropdown-item" onclick={() => {
|
<button
|
||||||
|
class="dropdown-item"
|
||||||
|
onclick={() => {
|
||||||
editor?.chain().focus().insertImagePlaceholder().run()
|
editor?.chain().focus().insertImagePlaceholder().run()
|
||||||
showMediaDropdown = false
|
showMediaDropdown = false
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" class="dropdown-icon">
|
<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"/>
|
<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" />
|
<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"/>
|
<path
|
||||||
|
d="M3 12L7 8L10 11L13 8L17 12"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
fill="none"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span>Image</span>
|
<span>Image</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="dropdown-item" onclick={() => {
|
<button
|
||||||
|
class="dropdown-item"
|
||||||
|
onclick={() => {
|
||||||
editor?.chain().focus().insertVideoPlaceholder().run()
|
editor?.chain().focus().insertVideoPlaceholder().run()
|
||||||
showMediaDropdown = false
|
showMediaDropdown = false
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" class="dropdown-icon">
|
<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"/>
|
<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" />
|
<path d="M8 8.5L12 10L8 11.5V8.5Z" fill="currentColor" />
|
||||||
</svg>
|
</svg>
|
||||||
<span>Video</span>
|
<span>Video</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="dropdown-item" onclick={() => {
|
<button
|
||||||
|
class="dropdown-item"
|
||||||
|
onclick={() => {
|
||||||
editor?.chain().focus().insertAudioPlaceholder().run()
|
editor?.chain().focus().insertAudioPlaceholder().run()
|
||||||
showMediaDropdown = false
|
showMediaDropdown = false
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" class="dropdown-icon">
|
<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>
|
</svg>
|
||||||
<span>Audio</span>
|
<span>Audio</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -491,61 +550,88 @@
|
||||||
style="position: fixed; top: {dropdownPosition.top}px; left: {dropdownPosition.left}px; z-index: 10000;"
|
style="position: fixed; top: {dropdownPosition.top}px; left: {dropdownPosition.left}px; z-index: 10000;"
|
||||||
>
|
>
|
||||||
<div class="dropdown-menu">
|
<div class="dropdown-menu">
|
||||||
<button class="dropdown-item" onclick={() => {
|
<button
|
||||||
|
class="dropdown-item"
|
||||||
|
onclick={() => {
|
||||||
editor?.chain().focus().setParagraph().run()
|
editor?.chain().focus().setParagraph().run()
|
||||||
showTextStyleDropdown = false
|
showTextStyleDropdown = false
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
Paragraph
|
Paragraph
|
||||||
</button>
|
</button>
|
||||||
<div class="dropdown-separator"></div>
|
<div class="dropdown-separator"></div>
|
||||||
<button class="dropdown-item" onclick={() => {
|
<button
|
||||||
|
class="dropdown-item"
|
||||||
|
onclick={() => {
|
||||||
editor?.chain().focus().toggleHeading({ level: 1 }).run()
|
editor?.chain().focus().toggleHeading({ level: 1 }).run()
|
||||||
showTextStyleDropdown = false
|
showTextStyleDropdown = false
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
Heading 1
|
Heading 1
|
||||||
</button>
|
</button>
|
||||||
<button class="dropdown-item" onclick={() => {
|
<button
|
||||||
|
class="dropdown-item"
|
||||||
|
onclick={() => {
|
||||||
editor?.chain().focus().toggleHeading({ level: 2 }).run()
|
editor?.chain().focus().toggleHeading({ level: 2 }).run()
|
||||||
showTextStyleDropdown = false
|
showTextStyleDropdown = false
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
Heading 2
|
Heading 2
|
||||||
</button>
|
</button>
|
||||||
<button class="dropdown-item" onclick={() => {
|
<button
|
||||||
|
class="dropdown-item"
|
||||||
|
onclick={() => {
|
||||||
editor?.chain().focus().toggleHeading({ level: 3 }).run()
|
editor?.chain().focus().toggleHeading({ level: 3 }).run()
|
||||||
showTextStyleDropdown = false
|
showTextStyleDropdown = false
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
Heading 3
|
Heading 3
|
||||||
</button>
|
</button>
|
||||||
<div class="dropdown-separator"></div>
|
<div class="dropdown-separator"></div>
|
||||||
<button class="dropdown-item" onclick={() => {
|
<button
|
||||||
|
class="dropdown-item"
|
||||||
|
onclick={() => {
|
||||||
editor?.chain().focus().toggleBulletList().run()
|
editor?.chain().focus().toggleBulletList().run()
|
||||||
showTextStyleDropdown = false
|
showTextStyleDropdown = false
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
Unordered List
|
Unordered List
|
||||||
</button>
|
</button>
|
||||||
<button class="dropdown-item" onclick={() => {
|
<button
|
||||||
|
class="dropdown-item"
|
||||||
|
onclick={() => {
|
||||||
editor?.chain().focus().toggleOrderedList().run()
|
editor?.chain().focus().toggleOrderedList().run()
|
||||||
showTextStyleDropdown = false
|
showTextStyleDropdown = false
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
Ordered List
|
Ordered List
|
||||||
</button>
|
</button>
|
||||||
<button class="dropdown-item" onclick={() => {
|
<button
|
||||||
|
class="dropdown-item"
|
||||||
|
onclick={() => {
|
||||||
editor?.chain().focus().toggleTaskList().run()
|
editor?.chain().focus().toggleTaskList().run()
|
||||||
showTextStyleDropdown = false
|
showTextStyleDropdown = false
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
Task List
|
Task List
|
||||||
</button>
|
</button>
|
||||||
<div class="dropdown-separator"></div>
|
<div class="dropdown-separator"></div>
|
||||||
<button class="dropdown-item" onclick={() => {
|
<button
|
||||||
|
class="dropdown-item"
|
||||||
|
onclick={() => {
|
||||||
editor?.chain().focus().toggleCodeBlock().run()
|
editor?.chain().focus().toggleCodeBlock().run()
|
||||||
showTextStyleDropdown = false
|
showTextStyleDropdown = false
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
Code Block
|
Code Block
|
||||||
</button>
|
</button>
|
||||||
<button class="dropdown-item" onclick={() => {
|
<button
|
||||||
|
class="dropdown-item"
|
||||||
|
onclick={() => {
|
||||||
editor?.chain().focus().toggleBlockquote().run()
|
editor?.chain().focus().toggleBlockquote().run()
|
||||||
showTextStyleDropdown = false
|
showTextStyleDropdown = false
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
Blockquote
|
Blockquote
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -693,7 +779,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.edra-toolbar button.active),
|
: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);
|
background: rgba(0, 0, 0, 0.1);
|
||||||
border-color: transparent;
|
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;
|
margin-bottom: $unit;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: $grey-20;
|
color: $grey-20;
|
||||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
|
||||||
font-size: 0.925rem;
|
font-size: 0.925rem;
|
||||||
|
|
||||||
.required {
|
.required {
|
||||||
|
|
@ -63,13 +62,11 @@
|
||||||
margin-top: $unit;
|
margin-top: $unit;
|
||||||
color: #c33;
|
color: #c33;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.help-text {
|
.help-text {
|
||||||
margin-top: $unit;
|
margin-top: $unit;
|
||||||
color: $grey-40;
|
color: $grey-40;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
|
||||||
}
|
}
|
||||||
</style>
|
</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>
|
||||||
|
|
@ -79,13 +79,17 @@
|
||||||
// Insert the uploaded image with reasonable default width
|
// Insert the uploaded image with reasonable default width
|
||||||
const displayWidth = media.width && media.width > 600 ? 600 : media.width
|
const displayWidth = media.width && media.width > 600 ? 600 : media.width
|
||||||
|
|
||||||
editor.chain().focus().setImage({
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.setImage({
|
||||||
src: media.url,
|
src: media.url,
|
||||||
alt: media.filename || '',
|
alt: media.filename || '',
|
||||||
width: displayWidth,
|
width: displayWidth,
|
||||||
height: media.height,
|
height: media.height,
|
||||||
align: 'center'
|
align: 'center'
|
||||||
}).run()
|
})
|
||||||
|
.run()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Image upload failed:', error)
|
console.error('Image upload failed:', error)
|
||||||
alert('Failed to upload image. Please try again.')
|
alert('Failed to upload image. Please try again.')
|
||||||
|
|
@ -141,9 +145,7 @@
|
||||||
<span class="edra-media-placeholder-text">
|
<span class="edra-media-placeholder-text">
|
||||||
{isDragging ? 'Drop image here' : 'Click to upload or drag & drop'}
|
{isDragging ? 'Drop image here' : 'Click to upload or drag & drop'}
|
||||||
</span>
|
</span>
|
||||||
<span class="edra-media-placeholder-subtext">
|
<span class="edra-media-placeholder-subtext"> or paste from clipboard </span>
|
||||||
or paste from clipboard
|
|
||||||
</span>
|
|
||||||
</span>
|
</span>
|
||||||
</NodeViewWrapper>
|
</NodeViewWrapper>
|
||||||
|
|
||||||
|
|
|
||||||
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,20 +1,46 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation'
|
import { goto } from '$app/navigation'
|
||||||
|
import UniverseComposer from './UniverseComposer.svelte'
|
||||||
|
import Button from './Button.svelte'
|
||||||
|
|
||||||
let isOpen = $state(false)
|
let isOpen = $state(false)
|
||||||
let buttonRef: HTMLButtonElement
|
let buttonRef: HTMLElement
|
||||||
|
let showComposer = $state(false)
|
||||||
|
let selectedType = $state<'post' | 'essay' | 'album'>('post')
|
||||||
|
|
||||||
const postTypes = [
|
const postTypes = [
|
||||||
{ value: 'blog', label: '📝 Blog Post', description: 'Long-form article' },
|
{ value: 'blog', label: 'Essay' },
|
||||||
{ value: 'microblog', label: '💭 Microblog', description: 'Short thought' },
|
{ value: 'microblog', label: 'Post' },
|
||||||
{ value: 'link', label: '🔗 Link', description: 'Share a link' },
|
{ value: 'link', label: 'Link' },
|
||||||
{ value: 'photo', label: '📷 Photo', description: 'Single photo post' },
|
{ value: 'photo', label: 'Photo' },
|
||||||
{ value: 'album', label: '🖼️ Album', description: 'Photo collection' }
|
{ value: 'album', label: 'Album' }
|
||||||
]
|
]
|
||||||
|
|
||||||
function handleSelection(type: string) {
|
function handleSelection(type: string) {
|
||||||
isOpen = false
|
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) {
|
function handleClickOutside(event: MouseEvent) {
|
||||||
|
|
@ -32,35 +58,150 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="dropdown-container">
|
<div class="dropdown-container">
|
||||||
<button
|
<Button
|
||||||
bind:this={buttonRef}
|
bind:this={buttonRef}
|
||||||
class="btn btn-primary"
|
variant="primary"
|
||||||
onclick={(e) => { e.stopPropagation(); isOpen = !isOpen }}
|
onclick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
isOpen = !isOpen
|
||||||
|
}}
|
||||||
|
iconPosition="right"
|
||||||
>
|
>
|
||||||
New Post
|
New Post
|
||||||
|
{#snippet icon()}
|
||||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" class="chevron">
|
<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"/>
|
<path
|
||||||
|
d="M3 4.5L6 7.5L9 4.5"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
{/snippet}
|
||||||
|
</Button>
|
||||||
|
|
||||||
{#if isOpen}
|
{#if isOpen}
|
||||||
<div class="dropdown-menu">
|
<div class="dropdown-menu">
|
||||||
{#each postTypes as type}
|
{#each postTypes as type}
|
||||||
<button
|
<Button
|
||||||
class="dropdown-item"
|
variant="ghost"
|
||||||
onclick={() => handleSelection(type.value)}
|
onclick={() => handleSelection(type.value)}
|
||||||
|
class="dropdown-item"
|
||||||
|
fullWidth
|
||||||
|
pill={false}
|
||||||
>
|
>
|
||||||
<span class="dropdown-icon">{type.label}</span>
|
{#snippet icon()}
|
||||||
<div class="dropdown-text">
|
<div class="dropdown-icon">
|
||||||
<span class="dropdown-label">{type.label.split(' ')[1]}</span>
|
{#if type.value === 'blog'}
|
||||||
<span class="dropdown-description">{type.description}</span>
|
<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>
|
</div>
|
||||||
</button>
|
{/snippet}
|
||||||
|
<span class="dropdown-label">{type.label}</span>
|
||||||
|
</Button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<UniverseComposer
|
||||||
|
bind:isOpen={showComposer}
|
||||||
|
initialPostType={selectedType}
|
||||||
|
on:close={handleComposerClose}
|
||||||
|
on:saved={handleComposerSaved}
|
||||||
|
on:switch-to-essay
|
||||||
|
/>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
@import '$styles/variables.scss';
|
@import '$styles/variables.scss';
|
||||||
|
|
||||||
|
|
@ -68,25 +209,17 @@
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn {
|
// Button styles are now handled by the Button component
|
||||||
padding: $unit-2x $unit-3x;
|
// Override primary button color to match original design
|
||||||
border: none;
|
:global(.dropdown-container .btn-primary) {
|
||||||
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;
|
|
||||||
|
|
||||||
&.btn-primary {
|
|
||||||
background-color: $grey-10;
|
background-color: $grey-10;
|
||||||
color: white;
|
|
||||||
|
|
||||||
&:hover {
|
&:hover:not(:disabled) {
|
||||||
background-color: $grey-20;
|
background-color: $grey-20;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&:active:not(:disabled) {
|
||||||
|
background-color: $grey-30;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -99,55 +232,37 @@
|
||||||
top: calc(100% + $unit);
|
top: calc(100% + $unit);
|
||||||
right: 0;
|
right: 0;
|
||||||
background: white;
|
background: white;
|
||||||
border: 1px solid $grey-80;
|
border: 1px solid $grey-85;
|
||||||
border-radius: 12px;
|
border-radius: $unit-2x;
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||||
min-width: 200px;
|
min-width: 220px;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-item {
|
// Override Button component styles for dropdown items
|
||||||
width: 100%;
|
:global(.dropdown-item) {
|
||||||
padding: $unit-2x;
|
justify-content: flex-start;
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: $unit-2x;
|
|
||||||
text-align: left;
|
text-align: left;
|
||||||
transition: background 0.2s ease;
|
padding: $unit-2x $unit-3x;
|
||||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
border-radius: 0;
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: $grey-95;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:not(:last-child) {
|
|
||||||
border-bottom: 1px solid $grey-90;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-icon {
|
.dropdown-icon {
|
||||||
font-size: 1.25rem;
|
color: $grey-40;
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-text {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
align-items: center;
|
||||||
gap: 2px;
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-label {
|
.dropdown-label {
|
||||||
font-size: 0.925rem;
|
font-size: 0.925rem;
|
||||||
font-weight: 600;
|
font-weight: 500;
|
||||||
color: $grey-10;
|
color: $grey-10;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-description {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: $grey-40;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
@ -1,60 +1,69 @@
|
||||||
<script lang="ts">
|
<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 { ProjectFormData } from '$lib/types/project'
|
||||||
|
import type { Media } from '@prisma/client'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
formData: ProjectFormData
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="form-section">
|
<div class="form-section">
|
||||||
<h2>Branding</h2>
|
<h2>Branding</h2>
|
||||||
|
|
||||||
<FormFieldWrapper
|
<ImageUploader
|
||||||
label="Logo"
|
label="Project Logo"
|
||||||
helpText="SVG logo for project thumbnail (max 500KB)"
|
value={logoMedia}
|
||||||
>
|
onUpload={handleLogoUpload}
|
||||||
<div class="logo-upload-wrapper">
|
aspectRatio="1:1"
|
||||||
{#if formData.logoUrl}
|
allowAltText={true}
|
||||||
<div class="logo-preview">
|
maxFileSize={0.5}
|
||||||
<img src={formData.logoUrl} alt="Project logo" />
|
placeholder="Drag and drop an SVG logo here, or click to browse"
|
||||||
<button
|
helpText="Upload an SVG logo for project thumbnail (max 500KB). Square logos work best."
|
||||||
type="button"
|
showBrowseLibrary={true}
|
||||||
class="remove-logo"
|
compact={true}
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
|
|
@ -70,117 +79,6 @@
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin: 0 0 $unit-3x;
|
margin: 0 0 $unit-3x;
|
||||||
color: $grey-10;
|
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">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte'
|
|
||||||
import { goto } from '$app/navigation'
|
import { goto } from '$app/navigation'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import AdminPage from './AdminPage.svelte'
|
import AdminPage from './AdminPage.svelte'
|
||||||
|
|
@ -8,7 +7,9 @@
|
||||||
import Editor from './Editor.svelte'
|
import Editor from './Editor.svelte'
|
||||||
import ProjectMetadataForm from './ProjectMetadataForm.svelte'
|
import ProjectMetadataForm from './ProjectMetadataForm.svelte'
|
||||||
import ProjectBrandingForm from './ProjectBrandingForm.svelte'
|
import ProjectBrandingForm from './ProjectBrandingForm.svelte'
|
||||||
|
import ProjectGalleryForm from './ProjectGalleryForm.svelte'
|
||||||
import ProjectStylingForm from './ProjectStylingForm.svelte'
|
import ProjectStylingForm from './ProjectStylingForm.svelte'
|
||||||
|
import Button from './Button.svelte'
|
||||||
import { projectSchema } from '$lib/schemas/project'
|
import { projectSchema } from '$lib/schemas/project'
|
||||||
import type { Project, ProjectFormData } from '$lib/types/project'
|
import type { Project, ProjectFormData } from '$lib/types/project'
|
||||||
import { defaultProjectFormData } from '$lib/types/project'
|
import { defaultProjectFormData } from '$lib/types/project'
|
||||||
|
|
@ -31,7 +32,6 @@
|
||||||
|
|
||||||
// Form data
|
// Form data
|
||||||
let formData = $state<ProjectFormData>({ ...defaultProjectFormData })
|
let formData = $state<ProjectFormData>({ ...defaultProjectFormData })
|
||||||
let logoUploadInProgress = $state(false)
|
|
||||||
|
|
||||||
// Ref to the editor component
|
// Ref to the editor component
|
||||||
let editorRef: any
|
let editorRef: any
|
||||||
|
|
@ -41,11 +41,11 @@
|
||||||
{ value: 'case-study', label: 'Case Study' }
|
{ value: 'case-study', label: 'Case Study' }
|
||||||
]
|
]
|
||||||
|
|
||||||
onMount(() => {
|
// Watch for project changes and populate form data
|
||||||
if (project) {
|
$effect(() => {
|
||||||
|
if (project && mode === 'edit') {
|
||||||
populateFormData(project)
|
populateFormData(project)
|
||||||
}
|
} else if (mode === 'create') {
|
||||||
if (mode === 'create') {
|
|
||||||
isLoading = false
|
isLoading = false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -60,9 +60,11 @@
|
||||||
role: data.role || '',
|
role: data.role || '',
|
||||||
technologies: Array.isArray(data.technologies) ? data.technologies.join(', ') : '',
|
technologies: Array.isArray(data.technologies) ? data.technologies.join(', ') : '',
|
||||||
externalUrl: data.externalUrl || '',
|
externalUrl: data.externalUrl || '',
|
||||||
|
featuredImage: data.featuredImage || null,
|
||||||
backgroundColor: data.backgroundColor || '',
|
backgroundColor: data.backgroundColor || '',
|
||||||
highlightColor: data.highlightColor || '',
|
highlightColor: data.highlightColor || '',
|
||||||
logoUrl: data.logoUrl || '',
|
logoUrl: data.logoUrl || '',
|
||||||
|
gallery: data.gallery || null,
|
||||||
status: (data.status as 'draft' | 'published') || 'draft',
|
status: (data.status as 'draft' | 'published') || 'draft',
|
||||||
caseStudyContent: data.caseStudyContent || {
|
caseStudyContent: data.caseStudyContent || {
|
||||||
type: 'doc',
|
type: 'doc',
|
||||||
|
|
@ -104,68 +106,6 @@
|
||||||
formData.caseStudyContent = content
|
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() {
|
async function handleSave() {
|
||||||
// Check if we're on the case study tab and should save editor content
|
// Check if we're on the case study tab and should save editor content
|
||||||
|
|
@ -204,12 +144,16 @@
|
||||||
.map((t) => t.trim())
|
.map((t) => t.trim())
|
||||||
.filter(Boolean),
|
.filter(Boolean),
|
||||||
externalUrl: formData.externalUrl,
|
externalUrl: formData.externalUrl,
|
||||||
|
featuredImage: formData.featuredImage,
|
||||||
logoUrl: formData.logoUrl,
|
logoUrl: formData.logoUrl,
|
||||||
|
gallery: formData.gallery && formData.gallery.length > 0 ? formData.gallery : null,
|
||||||
backgroundColor: formData.backgroundColor,
|
backgroundColor: formData.backgroundColor,
|
||||||
highlightColor: formData.highlightColor,
|
highlightColor: formData.highlightColor,
|
||||||
status: formData.status,
|
status: formData.status,
|
||||||
caseStudyContent:
|
caseStudyContent:
|
||||||
formData.caseStudyContent && formData.caseStudyContent.content && formData.caseStudyContent.content.length > 0
|
formData.caseStudyContent &&
|
||||||
|
formData.caseStudyContent.content &&
|
||||||
|
formData.caseStudyContent.content.length > 0
|
||||||
? formData.caseStudyContent
|
? formData.caseStudyContent
|
||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
|
|
@ -296,16 +240,20 @@
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
{#if !isLoading}
|
{#if !isLoading}
|
||||||
<div class="save-actions">
|
<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'}
|
{isSaving ? 'Saving...' : formData.status === 'published' ? 'Save' : 'Save Draft'}
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
class="btn btn-primary chevron-button"
|
variant="ghost"
|
||||||
class:active={showPublishMenu}
|
iconOnly
|
||||||
|
size="medium"
|
||||||
|
active={showPublishMenu}
|
||||||
onclick={togglePublishMenu}
|
onclick={togglePublishMenu}
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
|
class="chevron-button"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
|
slot="icon"
|
||||||
width="12"
|
width="12"
|
||||||
height="12"
|
height="12"
|
||||||
viewBox="0 0 12 12"
|
viewBox="0 0 12 12"
|
||||||
|
|
@ -320,13 +268,17 @@
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</Button>
|
||||||
{#if showPublishMenu}
|
{#if showPublishMenu}
|
||||||
<div class="publish-menu">
|
<div class="publish-menu">
|
||||||
{#if formData.status === 'published'}
|
{#if formData.status === 'published'}
|
||||||
<button onclick={handleUnpublish} class="menu-item"> Unpublish </button>
|
<Button variant="ghost" onclick={handleUnpublish} class="menu-item" fullWidth>
|
||||||
|
Unpublish
|
||||||
|
</Button>
|
||||||
{:else}
|
{:else}
|
||||||
<button onclick={handlePublish} class="menu-item"> Publish </button>
|
<Button variant="ghost" onclick={handlePublish} class="menu-item" fullWidth>
|
||||||
|
Publish
|
||||||
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -358,12 +310,8 @@
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ProjectMetadataForm bind:formData {validationErrors} />
|
<ProjectMetadataForm bind:formData {validationErrors} />
|
||||||
<ProjectBrandingForm
|
<ProjectBrandingForm bind:formData />
|
||||||
bind:formData
|
<ProjectGalleryForm bind:formData />
|
||||||
bind:logoUploadInProgress
|
|
||||||
onLogoUpload={handleLogoUpload}
|
|
||||||
onRemoveLogo={removeLogo}
|
|
||||||
/>
|
|
||||||
<ProjectStylingForm bind:formData {validationErrors} />
|
<ProjectStylingForm bind:formData {validationErrors} />
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -427,49 +375,23 @@
|
||||||
.save-actions {
|
.save-actions {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
gap: $unit-half;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn {
|
/* Button-specific styles handled by Button component */
|
||||||
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;
|
|
||||||
|
|
||||||
&:disabled {
|
/* Custom button styles */
|
||||||
opacity: 0.6;
|
:global(.save-button) {
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.btn-primary {
|
|
||||||
background-color: $grey-10;
|
|
||||||
color: white;
|
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
|
||||||
background-color: $grey-20;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.save-button {
|
|
||||||
border-top-right-radius: 0;
|
border-top-right-radius: 0;
|
||||||
border-bottom-right-radius: 0;
|
border-bottom-right-radius: 0;
|
||||||
padding-right: $unit-2x;
|
padding-right: $unit-2x;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chevron-button {
|
:global(.chevron-button) {
|
||||||
border-top-left-radius: 0;
|
border-top-left-radius: 0;
|
||||||
border-bottom-left-radius: 0;
|
border-bottom-left-radius: 0;
|
||||||
padding: $unit $unit;
|
|
||||||
border-left: 1px solid rgba(255, 255, 255, 0.2);
|
border-left: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
|
||||||
&.active {
|
|
||||||
background-color: $grey-20;
|
|
||||||
}
|
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
display: block;
|
display: block;
|
||||||
transition: transform 0.2s ease;
|
transition: transform 0.2s ease;
|
||||||
|
|
@ -492,22 +414,10 @@
|
||||||
min-width: 120px;
|
min-width: 120px;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
|
|
||||||
.menu-item {
|
/* Menu item styles handled by Button component */
|
||||||
display: block;
|
:global(.menu-item) {
|
||||||
width: 100%;
|
|
||||||
padding: $unit-2x $unit-3x;
|
|
||||||
text-align: left;
|
text-align: left;
|
||||||
background: none;
|
justify-content: flex-start;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -537,7 +447,6 @@
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: $unit-6x;
|
padding: $unit-6x;
|
||||||
color: $grey-40;
|
color: $grey-40;
|
||||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.error {
|
.error {
|
||||||
|
|
@ -549,7 +458,6 @@
|
||||||
padding: $unit-3x;
|
padding: $unit-3x;
|
||||||
border-radius: $unit;
|
border-radius: $unit;
|
||||||
margin-bottom: $unit-4x;
|
margin-bottom: $unit-4x;
|
||||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
|
||||||
max-width: 700px;
|
max-width: 700px;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
|
|
@ -571,6 +479,12 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-content form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $unit-6x;
|
||||||
|
}
|
||||||
|
|
||||||
.case-study-wrapper {
|
.case-study-wrapper {
|
||||||
background: white;
|
background: white;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|
|
||||||
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;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-metadata {
|
.project-metadata {
|
||||||
|
|
@ -178,7 +177,6 @@
|
||||||
gap: $unit;
|
gap: $unit;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: $grey-40;
|
color: $grey-40;
|
||||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
|
||||||
|
|
||||||
.status {
|
.status {
|
||||||
&.published {
|
&.published {
|
||||||
|
|
@ -239,7 +237,6 @@
|
||||||
color: $grey-20;
|
color: $grey-20;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background-color 0.2s ease;
|
transition: background-color 0.2s ease;
|
||||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: $grey-95;
|
background-color: $grey-95;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
<script lang="ts">
|
<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 { ProjectFormData } from '$lib/types/project'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -8,45 +9,71 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
let { formData = $bindable(), validationErrors }: Props = $props()
|
let { formData = $bindable(), validationErrors }: Props = $props()
|
||||||
|
|
||||||
|
function handleFeaturedImageUpload(media: Media) {
|
||||||
|
formData.featuredImage = media.url
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="form-section">
|
<div class="form-section">
|
||||||
<FormFieldWrapper label="Title" required error={validationErrors.title}>
|
<Input
|
||||||
<input type="text" bind:value={formData.title} required placeholder="Project title" />
|
label="Title"
|
||||||
</FormFieldWrapper>
|
required
|
||||||
|
error={validationErrors.title}
|
||||||
|
bind:value={formData.title}
|
||||||
|
placeholder="Project title"
|
||||||
|
/>
|
||||||
|
|
||||||
<FormFieldWrapper label="Description" error={validationErrors.description}>
|
<Input
|
||||||
<textarea
|
type="textarea"
|
||||||
|
label="Description"
|
||||||
|
error={validationErrors.description}
|
||||||
bind:value={formData.description}
|
bind:value={formData.description}
|
||||||
rows="3"
|
rows={3}
|
||||||
placeholder="Short description for project cards"
|
placeholder="Short description for project cards"
|
||||||
/>
|
/>
|
||||||
</FormFieldWrapper>
|
|
||||||
|
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<FormFieldWrapper label="Year" required error={validationErrors.year}>
|
<Input
|
||||||
<input
|
|
||||||
type="number"
|
type="number"
|
||||||
bind:value={formData.year}
|
label="Year"
|
||||||
required
|
required
|
||||||
min="1990"
|
error={validationErrors.year}
|
||||||
|
bind:value={formData.year}
|
||||||
|
min={1990}
|
||||||
max={new Date().getFullYear() + 1}
|
max={new Date().getFullYear() + 1}
|
||||||
/>
|
/>
|
||||||
</FormFieldWrapper>
|
|
||||||
|
|
||||||
<FormFieldWrapper label="Client" error={validationErrors.client}>
|
<Input
|
||||||
<input type="text" bind:value={formData.client} placeholder="Client or company name" />
|
label="Client"
|
||||||
</FormFieldWrapper>
|
error={validationErrors.client}
|
||||||
|
bind:value={formData.client}
|
||||||
|
placeholder="Client or company name"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FormFieldWrapper label="External URL" error={validationErrors.externalUrl}>
|
<Input
|
||||||
<input type="url" bind:value={formData.externalUrl} placeholder="https://example.com" />
|
type="url"
|
||||||
</FormFieldWrapper>
|
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>
|
</div>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
.form-section {
|
.form-section {
|
||||||
margin-bottom: $unit-6x;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $unit-2x;
|
||||||
|
|
||||||
&:last-child {
|
&:last-child {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
|
|
@ -62,39 +89,5 @@
|
||||||
@include breakpoint('phone') {
|
@include breakpoint('phone') {
|
||||||
grid-template-columns: 1fr;
|
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">
|
<script lang="ts">
|
||||||
import FormFieldWrapper from './FormFieldWrapper.svelte'
|
import Input from './Input.svelte'
|
||||||
import type { ProjectFormData } from '$lib/types/project'
|
import type { ProjectFormData } from '$lib/types/project'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -14,44 +14,27 @@
|
||||||
<h2>Styling</h2>
|
<h2>Styling</h2>
|
||||||
|
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<FormFieldWrapper
|
<Input
|
||||||
|
type="text"
|
||||||
|
bind:value={formData.backgroundColor}
|
||||||
label="Background Color"
|
label="Background Color"
|
||||||
helpText="Hex color for project card"
|
helpText="Hex color for project card"
|
||||||
error={validationErrors.backgroundColor}
|
error={validationErrors.backgroundColor}
|
||||||
>
|
|
||||||
<div class="color-input-wrapper">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
bind:value={formData.backgroundColor}
|
|
||||||
placeholder="#FFFFFF"
|
placeholder="#FFFFFF"
|
||||||
pattern="^#[0-9A-Fa-f]{6}$"
|
pattern="^#[0-9A-Fa-f]{6}$"
|
||||||
|
colorSwatch={true}
|
||||||
/>
|
/>
|
||||||
{#if formData.backgroundColor}
|
|
||||||
<div
|
|
||||||
class="color-preview"
|
|
||||||
style="background-color: {formData.backgroundColor}"
|
|
||||||
></div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</FormFieldWrapper>
|
|
||||||
|
|
||||||
<FormFieldWrapper
|
<Input
|
||||||
|
type="text"
|
||||||
|
bind:value={formData.highlightColor}
|
||||||
label="Highlight Color"
|
label="Highlight Color"
|
||||||
helpText="Accent color for the project"
|
helpText="Accent color for the project"
|
||||||
error={validationErrors.highlightColor}
|
error={validationErrors.highlightColor}
|
||||||
>
|
|
||||||
<div class="color-input-wrapper">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
bind:value={formData.highlightColor}
|
|
||||||
placeholder="#000000"
|
placeholder="#000000"
|
||||||
pattern="^#[0-9A-Fa-f]{6}$"
|
pattern="^#[0-9A-Fa-f]{6}$"
|
||||||
|
colorSwatch={true}
|
||||||
/>
|
/>
|
||||||
{#if formData.highlightColor}
|
|
||||||
<div class="color-preview" style="background-color: {formData.highlightColor}"></div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</FormFieldWrapper>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -68,7 +51,6 @@
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin: 0 0 $unit-3x;
|
margin: 0 0 $unit-3x;
|
||||||
color: $grey-10;
|
color: $grey-10;
|
||||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -81,45 +63,8 @@
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.form-field) {
|
:global(.input-wrapper) {
|
||||||
margin-bottom: 0;
|
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;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: $unit;
|
gap: $unit;
|
||||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.color-dot {
|
.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 { isMac } from '../utils.js'
|
||||||
import type { EdraCommandGroup } from './types.js';
|
import type { EdraCommandGroup } from './types.js'
|
||||||
|
|
||||||
export const commands: Record<string, EdraCommandGroup> = {
|
export const commands: Record<string, EdraCommandGroup> = {
|
||||||
'undo-redo': {
|
'undo-redo': {
|
||||||
|
|
@ -12,7 +12,7 @@ export const commands: Record<string, EdraCommandGroup> = {
|
||||||
label: 'Undo',
|
label: 'Undo',
|
||||||
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Z`],
|
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Z`],
|
||||||
action: (editor) => {
|
action: (editor) => {
|
||||||
editor.chain().focus().undo().run();
|
editor.chain().focus().undo().run()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -21,7 +21,7 @@ export const commands: Record<string, EdraCommandGroup> = {
|
||||||
label: 'Redo',
|
label: 'Redo',
|
||||||
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Y`, `${isMac ? 'Cmd' : 'Ctrl'}+Shift+Z`],
|
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Y`, `${isMac ? 'Cmd' : 'Ctrl'}+Shift+Z`],
|
||||||
action: (editor) => {
|
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',
|
label: 'Heading 1',
|
||||||
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Alt+1`],
|
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Alt+1`],
|
||||||
action: (editor) => {
|
action: (editor) => {
|
||||||
editor.chain().focus().toggleHeading({ level: 1 }).run();
|
editor.chain().focus().toggleHeading({ level: 1 }).run()
|
||||||
},
|
},
|
||||||
isActive: (editor) => editor.isActive('heading', { level: 1 })
|
isActive: (editor) => editor.isActive('heading', { level: 1 })
|
||||||
},
|
},
|
||||||
|
|
@ -46,7 +46,7 @@ export const commands: Record<string, EdraCommandGroup> = {
|
||||||
label: 'Heading 2',
|
label: 'Heading 2',
|
||||||
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Alt+2`],
|
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Alt+2`],
|
||||||
action: (editor) => {
|
action: (editor) => {
|
||||||
editor.chain().focus().toggleHeading({ level: 2 }).run();
|
editor.chain().focus().toggleHeading({ level: 2 }).run()
|
||||||
},
|
},
|
||||||
isActive: (editor) => editor.isActive('heading', { level: 2 })
|
isActive: (editor) => editor.isActive('heading', { level: 2 })
|
||||||
},
|
},
|
||||||
|
|
@ -56,7 +56,7 @@ export const commands: Record<string, EdraCommandGroup> = {
|
||||||
label: 'Heading 3',
|
label: 'Heading 3',
|
||||||
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Alt+3`],
|
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Alt+3`],
|
||||||
action: (editor) => {
|
action: (editor) => {
|
||||||
editor.chain().focus().toggleHeading({ level: 3 }).run();
|
editor.chain().focus().toggleHeading({ level: 3 }).run()
|
||||||
},
|
},
|
||||||
isActive: (editor) => editor.isActive('heading', { level: 3 })
|
isActive: (editor) => editor.isActive('heading', { level: 3 })
|
||||||
}
|
}
|
||||||
|
|
@ -72,8 +72,8 @@ export const commands: Record<string, EdraCommandGroup> = {
|
||||||
label: 'Link',
|
label: 'Link',
|
||||||
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+K`],
|
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+K`],
|
||||||
action: (editor) => {
|
action: (editor) => {
|
||||||
const href = prompt('Enter the URL of the link:');
|
const href = prompt('Enter the URL of the link:')
|
||||||
if (href !== null) editor.chain().focus().setLink({ href, target: '_blank' }).run();
|
if (href !== null) editor.chain().focus().setLink({ href, target: '_blank' }).run()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -82,7 +82,7 @@ export const commands: Record<string, EdraCommandGroup> = {
|
||||||
label: 'Bold',
|
label: 'Bold',
|
||||||
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+B`],
|
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+B`],
|
||||||
action: (editor) => {
|
action: (editor) => {
|
||||||
editor.chain().focus().toggleBold().run();
|
editor.chain().focus().toggleBold().run()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -91,7 +91,7 @@ export const commands: Record<string, EdraCommandGroup> = {
|
||||||
label: 'Italic',
|
label: 'Italic',
|
||||||
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+I`],
|
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+I`],
|
||||||
action: (editor) => {
|
action: (editor) => {
|
||||||
editor.chain().focus().toggleItalic().run();
|
editor.chain().focus().toggleItalic().run()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -100,7 +100,7 @@ export const commands: Record<string, EdraCommandGroup> = {
|
||||||
label: 'Underline',
|
label: 'Underline',
|
||||||
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+U`],
|
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+U`],
|
||||||
action: (editor) => {
|
action: (editor) => {
|
||||||
editor.chain().focus().toggleUnderline().run();
|
editor.chain().focus().toggleUnderline().run()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -109,7 +109,7 @@ export const commands: Record<string, EdraCommandGroup> = {
|
||||||
label: 'Strikethrough',
|
label: 'Strikethrough',
|
||||||
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Shift+S`],
|
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Shift+S`],
|
||||||
action: (editor) => {
|
action: (editor) => {
|
||||||
editor.chain().focus().toggleStrike().run();
|
editor.chain().focus().toggleStrike().run()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -118,7 +118,7 @@ export const commands: Record<string, EdraCommandGroup> = {
|
||||||
label: 'Blockquote',
|
label: 'Blockquote',
|
||||||
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Shift+B`],
|
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Shift+B`],
|
||||||
action: (editor) => {
|
action: (editor) => {
|
||||||
editor.chain().focus().toggleBlockquote().run();
|
editor.chain().focus().toggleBlockquote().run()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -127,7 +127,7 @@ export const commands: Record<string, EdraCommandGroup> = {
|
||||||
label: 'Superscript',
|
label: 'Superscript',
|
||||||
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Period`],
|
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Period`],
|
||||||
action: (editor) => {
|
action: (editor) => {
|
||||||
editor.chain().focus().toggleSuperscript().run();
|
editor.chain().focus().toggleSuperscript().run()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -136,7 +136,7 @@ export const commands: Record<string, EdraCommandGroup> = {
|
||||||
label: 'Subscript',
|
label: 'Subscript',
|
||||||
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Comma`],
|
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Comma`],
|
||||||
action: (editor) => {
|
action: (editor) => {
|
||||||
editor.chain().focus().toggleSubscript().run();
|
editor.chain().focus().toggleSubscript().run()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -145,7 +145,7 @@ export const commands: Record<string, EdraCommandGroup> = {
|
||||||
label: 'Code',
|
label: 'Code',
|
||||||
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+E`],
|
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+E`],
|
||||||
action: (editor) => {
|
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',
|
label: 'Code Block',
|
||||||
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Alt+C`],
|
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Alt+C`],
|
||||||
action: (editor) => {
|
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',
|
label: 'Align Left',
|
||||||
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Shift+L`],
|
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Shift+L`],
|
||||||
action: (editor) => {
|
action: (editor) => {
|
||||||
editor.chain().focus().setTextAlign('left').run();
|
editor.chain().focus().setTextAlign('left').run()
|
||||||
},
|
},
|
||||||
isActive: (editor) => editor.isActive({ textAlign: 'left' })
|
isActive: (editor) => editor.isActive({ textAlign: 'left' })
|
||||||
},
|
},
|
||||||
|
|
@ -179,7 +179,7 @@ export const commands: Record<string, EdraCommandGroup> = {
|
||||||
label: 'Align Center',
|
label: 'Align Center',
|
||||||
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Shift+E`],
|
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Shift+E`],
|
||||||
action: (editor) => {
|
action: (editor) => {
|
||||||
editor.chain().focus().setTextAlign('center').run();
|
editor.chain().focus().setTextAlign('center').run()
|
||||||
},
|
},
|
||||||
isActive: (editor) => editor.isActive({ textAlign: 'center' })
|
isActive: (editor) => editor.isActive({ textAlign: 'center' })
|
||||||
},
|
},
|
||||||
|
|
@ -189,7 +189,7 @@ export const commands: Record<string, EdraCommandGroup> = {
|
||||||
label: 'Align Right',
|
label: 'Align Right',
|
||||||
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Shift+R`],
|
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Shift+R`],
|
||||||
action: (editor) => {
|
action: (editor) => {
|
||||||
editor.chain().focus().setTextAlign('right').run();
|
editor.chain().focus().setTextAlign('right').run()
|
||||||
},
|
},
|
||||||
isActive: (editor) => editor.isActive({ textAlign: 'right' })
|
isActive: (editor) => editor.isActive({ textAlign: 'right' })
|
||||||
},
|
},
|
||||||
|
|
@ -199,7 +199,7 @@ export const commands: Record<string, EdraCommandGroup> = {
|
||||||
label: 'Align Justify',
|
label: 'Align Justify',
|
||||||
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Shift+J`],
|
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Shift+J`],
|
||||||
action: (editor) => {
|
action: (editor) => {
|
||||||
editor.chain().focus().setTextAlign('justify').run();
|
editor.chain().focus().setTextAlign('justify').run()
|
||||||
},
|
},
|
||||||
isActive: (editor) => editor.isActive({ textAlign: 'justify' })
|
isActive: (editor) => editor.isActive({ textAlign: 'justify' })
|
||||||
}
|
}
|
||||||
|
|
@ -215,7 +215,7 @@ export const commands: Record<string, EdraCommandGroup> = {
|
||||||
label: 'Bullet List',
|
label: 'Bullet List',
|
||||||
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Shift+8`],
|
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Shift+8`],
|
||||||
action: (editor) => {
|
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',
|
label: 'Ordered List',
|
||||||
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Shift+7`],
|
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Shift+7`],
|
||||||
action: (editor) => {
|
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',
|
label: 'Task List',
|
||||||
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Shift+9`],
|
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Shift+9`],
|
||||||
action: (editor) => {
|
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',
|
name: 'audio-placeholder',
|
||||||
label: 'Audio',
|
label: 'Audio',
|
||||||
action: (editor) => {
|
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',
|
name: 'image-placeholder',
|
||||||
label: 'Image',
|
label: 'Image',
|
||||||
action: (editor) => {
|
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',
|
name: 'video-placeholder',
|
||||||
label: 'Video',
|
label: 'Video',
|
||||||
action: (editor) => {
|
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',
|
name: 'iframe-placeholder',
|
||||||
label: 'IFrame',
|
label: 'IFrame',
|
||||||
action: (editor) => {
|
action: (editor) => {
|
||||||
editor.chain().focus().insertIFramePlaceholder().run();
|
editor.chain().focus().insertIFramePlaceholder().run()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
@ -286,7 +286,7 @@ export const commands: Record<string, EdraCommandGroup> = {
|
||||||
label: 'Color',
|
label: 'Color',
|
||||||
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Shift+C`],
|
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Shift+C`],
|
||||||
action: (editor) => {
|
action: (editor) => {
|
||||||
editor.chain().focus().unsetColor().run();
|
editor.chain().focus().unsetColor().run()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -295,7 +295,7 @@ export const commands: Record<string, EdraCommandGroup> = {
|
||||||
label: 'Highlight',
|
label: 'Highlight',
|
||||||
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Shift+H`],
|
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Shift+H`],
|
||||||
action: (editor) => {
|
action: (editor) => {
|
||||||
editor.chain().focus().toggleHighlight().run();
|
editor.chain().focus().toggleHighlight().run()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
@ -310,8 +310,8 @@ export const commands: Record<string, EdraCommandGroup> = {
|
||||||
label: 'Table',
|
label: 'Table',
|
||||||
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Shift+T`],
|
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Shift+T`],
|
||||||
action: (editor) => {
|
action: (editor) => {
|
||||||
if (editor.isActive('table')) editor.chain().focus().deleteTable().run();
|
if (editor.isActive('table')) editor.chain().focus().deleteTable().run()
|
||||||
else editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: false }).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',
|
name: 'font increment',
|
||||||
label: 'Increase Font Size',
|
label: 'Increase Font Size',
|
||||||
action: (editor) => {
|
action: (editor) => {
|
||||||
let currentFontSize = parseInt(editor.getAttributes('textStyle').fontSize ?? '16px');
|
let currentFontSize = parseInt(editor.getAttributes('textStyle').fontSize ?? '16px')
|
||||||
currentFontSize++;
|
currentFontSize++
|
||||||
editor.chain().focus().setFontSize(`${currentFontSize}px`).run();
|
editor.chain().focus().setFontSize(`${currentFontSize}px`).run()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -335,11 +335,11 @@ export const commands: Record<string, EdraCommandGroup> = {
|
||||||
name: 'font decrement',
|
name: 'font decrement',
|
||||||
label: 'Decrease Font Size',
|
label: 'Decrease Font Size',
|
||||||
action: (editor) => {
|
action: (editor) => {
|
||||||
let currentFontSize = parseInt(editor.getAttributes('textStyle').fontSize ?? '16px');
|
let currentFontSize = parseInt(editor.getAttributes('textStyle').fontSize ?? '16px')
|
||||||
currentFontSize--;
|
currentFontSize--
|
||||||
editor.chain().focus().setFontSize(`${currentFontSize}px`).run();
|
editor.chain().focus().setFontSize(`${currentFontSize}px`).run()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,21 @@
|
||||||
import type { Editor } from '@tiptap/core';
|
import type { Editor } from '@tiptap/core'
|
||||||
import type { icons } from 'lucide-svelte';
|
import type { icons } from 'lucide-svelte'
|
||||||
|
|
||||||
export interface EdraCommand {
|
export interface EdraCommand {
|
||||||
iconName: keyof typeof icons;
|
iconName: keyof typeof icons
|
||||||
name: string;
|
name: string
|
||||||
label: string;
|
label: string
|
||||||
shortCuts?: string[];
|
shortCuts?: string[]
|
||||||
action: (editor: Editor) => void;
|
action: (editor: Editor) => void
|
||||||
isActive?: (editor: Editor) => boolean;
|
isActive?: (editor: Editor) => boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EdraCommandShortCuts {
|
export interface EdraCommandShortCuts {
|
||||||
key: string;
|
key: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EdraCommandGroup {
|
export interface EdraCommandGroup {
|
||||||
name: string;
|
name: string
|
||||||
label: string;
|
label: string
|
||||||
commands: EdraCommand[];
|
commands: EdraCommand[]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,16 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Editor } from '@tiptap/core';
|
import type { Editor } from '@tiptap/core'
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte'
|
||||||
import GripVertical from 'lucide-svelte/icons/grip-vertical';
|
import GripVertical from 'lucide-svelte/icons/grip-vertical'
|
||||||
import { DragHandlePlugin } from './extensions/drag-handle/index.js';
|
import { DragHandlePlugin } from './extensions/drag-handle/index.js'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
editor: Editor;
|
editor: Editor
|
||||||
}
|
}
|
||||||
|
|
||||||
const { editor }: Props = $props();
|
const { editor }: Props = $props()
|
||||||
|
|
||||||
const pluginKey = 'globalDragHandle';
|
const pluginKey = 'globalDragHandle'
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const plugin = DragHandlePlugin({
|
const plugin = DragHandlePlugin({
|
||||||
|
|
@ -20,10 +20,10 @@
|
||||||
dragHandleSelector: '.drag-handle',
|
dragHandleSelector: '.drag-handle',
|
||||||
excludedTags: ['pre', 'code', 'table p'],
|
excludedTags: ['pre', 'code', 'table p'],
|
||||||
customNodes: []
|
customNodes: []
|
||||||
});
|
})
|
||||||
editor.registerPlugin(plugin);
|
editor.registerPlugin(plugin)
|
||||||
return () => editor.unregisterPlugin(pluginKey);
|
return () => editor.unregisterPlugin(pluginKey)
|
||||||
});
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="drag-handle">
|
<div class="drag-handle">
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,28 @@
|
||||||
import { Editor, type Content, type EditorOptions, type Extensions } from '@tiptap/core';
|
import { Editor, type Content, type EditorOptions, type Extensions } from '@tiptap/core'
|
||||||
import Color from '@tiptap/extension-color';
|
import Color from '@tiptap/extension-color'
|
||||||
import Link from '@tiptap/extension-link';
|
import Link from '@tiptap/extension-link'
|
||||||
import Subscript from '@tiptap/extension-subscript';
|
import Subscript from '@tiptap/extension-subscript'
|
||||||
import Superscript from '@tiptap/extension-superscript';
|
import Superscript from '@tiptap/extension-superscript'
|
||||||
import TaskItem from '@tiptap/extension-task-item';
|
import TaskItem from '@tiptap/extension-task-item'
|
||||||
import TaskList from '@tiptap/extension-task-list';
|
import TaskList from '@tiptap/extension-task-list'
|
||||||
import TextAlign from '@tiptap/extension-text-align';
|
import TextAlign from '@tiptap/extension-text-align'
|
||||||
import TextStyle from '@tiptap/extension-text-style';
|
import TextStyle from '@tiptap/extension-text-style'
|
||||||
import Typography from '@tiptap/extension-typography';
|
import Typography from '@tiptap/extension-typography'
|
||||||
import Underline from '@tiptap/extension-underline';
|
import Underline from '@tiptap/extension-underline'
|
||||||
import StarterKit from '@tiptap/starter-kit';
|
import StarterKit from '@tiptap/starter-kit'
|
||||||
import Highlight from '@tiptap/extension-highlight';
|
import Highlight from '@tiptap/extension-highlight'
|
||||||
import Text from '@tiptap/extension-text';
|
import Text from '@tiptap/extension-text'
|
||||||
import { SmilieReplacer } from './extensions/SmilieReplacer.js';
|
import { SmilieReplacer } from './extensions/SmilieReplacer.js'
|
||||||
import { ColorHighlighter } from './extensions/ColorHighlighter.js';
|
import { ColorHighlighter } from './extensions/ColorHighlighter.js'
|
||||||
import AutoJoiner from 'tiptap-extension-auto-joiner';
|
import AutoJoiner from 'tiptap-extension-auto-joiner'
|
||||||
import { MathExtension } from '@aarkue/tiptap-math-extension';
|
import { MathExtension } from '@aarkue/tiptap-math-extension'
|
||||||
import { Table, TableCell, TableHeader, TableRow } from './extensions/table/index.js';
|
import { Table, TableCell, TableHeader, TableRow } from './extensions/table/index.js'
|
||||||
import FontSize from './extensions/FontSize.js';
|
import FontSize from './extensions/FontSize.js'
|
||||||
import Placeholder from '@tiptap/extension-placeholder';
|
import Placeholder from '@tiptap/extension-placeholder'
|
||||||
import CharacterCount from '@tiptap/extension-character-count';
|
import CharacterCount from '@tiptap/extension-character-count'
|
||||||
import SearchAndReplace from './extensions/FindAndReplace.js';
|
import SearchAndReplace from './extensions/FindAndReplace.js'
|
||||||
import { getHandlePaste } from './utils.js';
|
import { getHandlePaste } from './utils.js'
|
||||||
import { Markdown } from 'tiptap-markdown';
|
import { Markdown } from 'tiptap-markdown'
|
||||||
|
|
||||||
export const initiateEditor = (
|
export const initiateEditor = (
|
||||||
element?: HTMLElement,
|
element?: HTMLElement,
|
||||||
|
|
@ -104,11 +104,11 @@ export const initiateEditor = (
|
||||||
// Use different placeholders depending on the node type:
|
// Use different placeholders depending on the node type:
|
||||||
placeholder: ({ node }) => {
|
placeholder: ({ node }) => {
|
||||||
if (node.type.name === 'heading') {
|
if (node.type.name === 'heading') {
|
||||||
return 'What’s the title?';
|
return 'What’s the title?'
|
||||||
} else if (node.type.name === 'paragraph') {
|
} else if (node.type.name === 'paragraph') {
|
||||||
return 'Press / or write something ...';
|
return 'Press / or write something ...'
|
||||||
}
|
}
|
||||||
return '';
|
return ''
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
CharacterCount.configure({
|
CharacterCount.configure({
|
||||||
|
|
@ -120,12 +120,12 @@ export const initiateEditor = (
|
||||||
],
|
],
|
||||||
autofocus: true,
|
autofocus: true,
|
||||||
...options
|
...options
|
||||||
});
|
})
|
||||||
|
|
||||||
editor.setOptions({
|
editor.setOptions({
|
||||||
editorProps: {
|
editorProps: {
|
||||||
handlePaste: getHandlePaste(editor)
|
handlePaste: getHandlePaste(editor)
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
return editor;
|
return editor
|
||||||
};
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { Extension } from '@tiptap/core';
|
import { Extension } from '@tiptap/core'
|
||||||
import { Plugin } from '@tiptap/pm/state';
|
import { Plugin } from '@tiptap/pm/state'
|
||||||
|
|
||||||
import { findColors } from '../utils.js';
|
import { findColors } from '../utils.js'
|
||||||
|
|
||||||
export const ColorHighlighter = Extension.create({
|
export const ColorHighlighter = Extension.create({
|
||||||
name: 'colorHighlighter',
|
name: 'colorHighlighter',
|
||||||
|
|
@ -11,18 +11,18 @@ export const ColorHighlighter = Extension.create({
|
||||||
new Plugin({
|
new Plugin({
|
||||||
state: {
|
state: {
|
||||||
init(_, { doc }) {
|
init(_, { doc }) {
|
||||||
return findColors(doc);
|
return findColors(doc)
|
||||||
},
|
},
|
||||||
apply(transaction, oldState) {
|
apply(transaction, oldState) {
|
||||||
return transaction.docChanged ? findColors(transaction.doc) : oldState;
|
return transaction.docChanged ? findColors(transaction.doc) : oldState
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
decorations(state) {
|
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
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
// SOFTWARE.
|
// SOFTWARE.
|
||||||
|
|
||||||
import { Extension, type Range, type Dispatch } from '@tiptap/core';
|
import { Extension, type Range, type Dispatch } from '@tiptap/core'
|
||||||
import { Decoration, DecorationSet } from '@tiptap/pm/view';
|
import { Decoration, DecorationSet } from '@tiptap/pm/view'
|
||||||
import { Plugin, PluginKey, type EditorState, type Transaction } from '@tiptap/pm/state';
|
import { Plugin, PluginKey, type EditorState, type Transaction } from '@tiptap/pm/state'
|
||||||
import { Node as PMNode } from '@tiptap/pm/model';
|
import { Node as PMNode } from '@tiptap/pm/model'
|
||||||
|
|
||||||
declare module '@tiptap/core' {
|
declare module '@tiptap/core' {
|
||||||
interface Commands<ReturnType> {
|
interface Commands<ReturnType> {
|
||||||
|
|
@ -31,54 +31,54 @@ declare module '@tiptap/core' {
|
||||||
/**
|
/**
|
||||||
* @description Set search term in extension.
|
* @description Set search term in extension.
|
||||||
*/
|
*/
|
||||||
setSearchTerm: (searchTerm: string) => ReturnType;
|
setSearchTerm: (searchTerm: string) => ReturnType
|
||||||
/**
|
/**
|
||||||
* @description Set replace term in extension.
|
* @description Set replace term in extension.
|
||||||
*/
|
*/
|
||||||
setReplaceTerm: (replaceTerm: string) => ReturnType;
|
setReplaceTerm: (replaceTerm: string) => ReturnType
|
||||||
/**
|
/**
|
||||||
* @description Set case sensitivity in extension.
|
* @description Set case sensitivity in extension.
|
||||||
*/
|
*/
|
||||||
setCaseSensitive: (caseSensitive: boolean) => ReturnType;
|
setCaseSensitive: (caseSensitive: boolean) => ReturnType
|
||||||
/**
|
/**
|
||||||
* @description Reset current search result to first instance.
|
* @description Reset current search result to first instance.
|
||||||
*/
|
*/
|
||||||
resetIndex: () => ReturnType;
|
resetIndex: () => ReturnType
|
||||||
/**
|
/**
|
||||||
* @description Find next instance of search result.
|
* @description Find next instance of search result.
|
||||||
*/
|
*/
|
||||||
nextSearchResult: () => ReturnType;
|
nextSearchResult: () => ReturnType
|
||||||
/**
|
/**
|
||||||
* @description Find previous instance of search result.
|
* @description Find previous instance of search result.
|
||||||
*/
|
*/
|
||||||
previousSearchResult: () => ReturnType;
|
previousSearchResult: () => ReturnType
|
||||||
/**
|
/**
|
||||||
* @description Replace first instance of search result with given replace term.
|
* @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.
|
* @description Replace all instances of search result with given replace term.
|
||||||
*/
|
*/
|
||||||
replaceAll: () => ReturnType;
|
replaceAll: () => ReturnType
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TextNodesWithPosition {
|
interface TextNodesWithPosition {
|
||||||
text: string;
|
text: string
|
||||||
pos: number;
|
pos: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const getRegex = (s: string, disableRegex: boolean, caseSensitive: boolean): RegExp => {
|
const getRegex = (s: string, disableRegex: boolean, caseSensitive: boolean): RegExp => {
|
||||||
return RegExp(
|
return RegExp(
|
||||||
disableRegex ? s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') : s,
|
disableRegex ? s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') : s,
|
||||||
caseSensitive ? 'gu' : 'gui'
|
caseSensitive ? 'gu' : 'gui'
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|
||||||
interface ProcessedSearches {
|
interface ProcessedSearches {
|
||||||
decorationsToReturn: DecorationSet;
|
decorationsToReturn: DecorationSet
|
||||||
results: Range[];
|
results: Range[]
|
||||||
}
|
}
|
||||||
|
|
||||||
function processSearches(
|
function processSearches(
|
||||||
|
|
@ -87,17 +87,17 @@ function processSearches(
|
||||||
searchResultClass: string,
|
searchResultClass: string,
|
||||||
resultIndex: number
|
resultIndex: number
|
||||||
): ProcessedSearches {
|
): ProcessedSearches {
|
||||||
const decorations: Decoration[] = [];
|
const decorations: Decoration[] = []
|
||||||
const results: Range[] = [];
|
const results: Range[] = []
|
||||||
|
|
||||||
let textNodesWithPosition: TextNodesWithPosition[] = [];
|
let textNodesWithPosition: TextNodesWithPosition[] = []
|
||||||
let index = 0;
|
let index = 0
|
||||||
|
|
||||||
if (!searchTerm) {
|
if (!searchTerm) {
|
||||||
return {
|
return {
|
||||||
decorationsToReturn: DecorationSet.empty,
|
decorationsToReturn: DecorationSet.empty,
|
||||||
results: []
|
results: []
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
doc?.descendants((node, pos) => {
|
doc?.descendants((node, pos) => {
|
||||||
|
|
@ -106,51 +106,51 @@ function processSearches(
|
||||||
textNodesWithPosition[index] = {
|
textNodesWithPosition[index] = {
|
||||||
text: textNodesWithPosition[index].text + node.text,
|
text: textNodesWithPosition[index].text + node.text,
|
||||||
pos: textNodesWithPosition[index].pos
|
pos: textNodesWithPosition[index].pos
|
||||||
};
|
}
|
||||||
} else {
|
} else {
|
||||||
textNodesWithPosition[index] = {
|
textNodesWithPosition[index] = {
|
||||||
text: `${node.text}`,
|
text: `${node.text}`,
|
||||||
pos
|
pos
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
index += 1;
|
index += 1
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
|
|
||||||
textNodesWithPosition = textNodesWithPosition.filter(Boolean);
|
textNodesWithPosition = textNodesWithPosition.filter(Boolean)
|
||||||
|
|
||||||
for (const element of textNodesWithPosition) {
|
for (const element of textNodesWithPosition) {
|
||||||
const { text, pos } = element;
|
const { text, pos } = element
|
||||||
const matches = Array.from(text.matchAll(searchTerm)).filter(([matchText]) => matchText.trim());
|
const matches = Array.from(text.matchAll(searchTerm)).filter(([matchText]) => matchText.trim())
|
||||||
|
|
||||||
for (const m of matches) {
|
for (const m of matches) {
|
||||||
if (m[0] === '') break;
|
if (m[0] === '') break
|
||||||
|
|
||||||
if (m.index !== undefined) {
|
if (m.index !== undefined) {
|
||||||
results.push({
|
results.push({
|
||||||
from: pos + m.index,
|
from: pos + m.index,
|
||||||
to: pos + m.index + m[0].length
|
to: pos + m.index + m[0].length
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let i = 0; i < results.length; i += 1) {
|
for (let i = 0; i < results.length; i += 1) {
|
||||||
const r = results[i];
|
const r = results[i]
|
||||||
const className =
|
const className =
|
||||||
i === resultIndex ? `${searchResultClass} ${searchResultClass}-current` : searchResultClass;
|
i === resultIndex ? `${searchResultClass} ${searchResultClass}-current` : searchResultClass
|
||||||
const decoration: Decoration = Decoration.inline(r.from, r.to, {
|
const decoration: Decoration = Decoration.inline(r.from, r.to, {
|
||||||
class: className
|
class: className
|
||||||
});
|
})
|
||||||
|
|
||||||
decorations.push(decoration);
|
decorations.push(decoration)
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
decorationsToReturn: DecorationSet.create(doc, decorations),
|
decorationsToReturn: DecorationSet.create(doc, decorations),
|
||||||
results
|
results
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const replace = (
|
const replace = (
|
||||||
|
|
@ -158,14 +158,14 @@ const replace = (
|
||||||
results: Range[],
|
results: Range[],
|
||||||
{ state, dispatch }: { state: EditorState; dispatch: Dispatch }
|
{ 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 = (
|
const rebaseNextResult = (
|
||||||
replaceTerm: string,
|
replaceTerm: string,
|
||||||
|
|
@ -173,69 +173,69 @@ const rebaseNextResult = (
|
||||||
lastOffset: number,
|
lastOffset: number,
|
||||||
results: Range[]
|
results: Range[]
|
||||||
): [number, Range[]] | null => {
|
): [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] = {
|
results[nextIndex] = {
|
||||||
to: to - offset,
|
to: to - offset,
|
||||||
from: from - offset
|
from: from - offset
|
||||||
};
|
}
|
||||||
|
|
||||||
return [offset, results];
|
return [offset, results]
|
||||||
};
|
}
|
||||||
|
|
||||||
const replaceAll = (
|
const replaceAll = (
|
||||||
replaceTerm: string,
|
replaceTerm: string,
|
||||||
results: Range[],
|
results: Range[],
|
||||||
{ tr, dispatch }: { tr: Transaction; dispatch: Dispatch }
|
{ 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) {
|
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];
|
offset = rebaseNextResultResponse[0]
|
||||||
resultsCopy = rebaseNextResultResponse[1];
|
resultsCopy = rebaseNextResultResponse[1]
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dispatch) {
|
if (dispatch) {
|
||||||
dispatch(tr);
|
dispatch(tr)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
export const searchAndReplacePluginKey = new PluginKey('searchAndReplacePlugin');
|
export const searchAndReplacePluginKey = new PluginKey('searchAndReplacePlugin')
|
||||||
|
|
||||||
export interface SearchAndReplaceOptions {
|
export interface SearchAndReplaceOptions {
|
||||||
searchResultClass: string;
|
searchResultClass: string
|
||||||
disableRegex: boolean;
|
disableRegex: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SearchAndReplaceStorage {
|
export interface SearchAndReplaceStorage {
|
||||||
searchTerm: string;
|
searchTerm: string
|
||||||
replaceTerm: string;
|
replaceTerm: string
|
||||||
results: Range[];
|
results: Range[]
|
||||||
lastSearchTerm: string;
|
lastSearchTerm: string
|
||||||
caseSensitive: boolean;
|
caseSensitive: boolean
|
||||||
lastCaseSensitive: boolean;
|
lastCaseSensitive: boolean
|
||||||
resultIndex: number;
|
resultIndex: number
|
||||||
lastResultIndex: number;
|
lastResultIndex: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SearchAndReplace = Extension.create<SearchAndReplaceOptions, SearchAndReplaceStorage>({
|
export const SearchAndReplace = Extension.create<SearchAndReplaceOptions, SearchAndReplaceStorage>({
|
||||||
|
|
@ -245,7 +245,7 @@ export const SearchAndReplace = Extension.create<SearchAndReplaceOptions, Search
|
||||||
return {
|
return {
|
||||||
searchResultClass: 'search-result',
|
searchResultClass: 'search-result',
|
||||||
disableRegex: true
|
disableRegex: true
|
||||||
};
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
addStorage() {
|
addStorage() {
|
||||||
|
|
@ -258,7 +258,7 @@ export const SearchAndReplace = Extension.create<SearchAndReplaceOptions, Search
|
||||||
lastCaseSensitive: false,
|
lastCaseSensitive: false,
|
||||||
resultIndex: 0,
|
resultIndex: 0,
|
||||||
lastResultIndex: 0
|
lastResultIndex: 0
|
||||||
};
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
addCommands() {
|
addCommands() {
|
||||||
|
|
@ -266,90 +266,90 @@ export const SearchAndReplace = Extension.create<SearchAndReplaceOptions, Search
|
||||||
setSearchTerm:
|
setSearchTerm:
|
||||||
(searchTerm: string) =>
|
(searchTerm: string) =>
|
||||||
({ editor }) => {
|
({ editor }) => {
|
||||||
editor.storage.searchAndReplace.searchTerm = searchTerm;
|
editor.storage.searchAndReplace.searchTerm = searchTerm
|
||||||
|
|
||||||
return false;
|
return false
|
||||||
},
|
},
|
||||||
setReplaceTerm:
|
setReplaceTerm:
|
||||||
(replaceTerm: string) =>
|
(replaceTerm: string) =>
|
||||||
({ editor }) => {
|
({ editor }) => {
|
||||||
editor.storage.searchAndReplace.replaceTerm = replaceTerm;
|
editor.storage.searchAndReplace.replaceTerm = replaceTerm
|
||||||
|
|
||||||
return false;
|
return false
|
||||||
},
|
},
|
||||||
setCaseSensitive:
|
setCaseSensitive:
|
||||||
(caseSensitive: boolean) =>
|
(caseSensitive: boolean) =>
|
||||||
({ editor }) => {
|
({ editor }) => {
|
||||||
editor.storage.searchAndReplace.caseSensitive = caseSensitive;
|
editor.storage.searchAndReplace.caseSensitive = caseSensitive
|
||||||
|
|
||||||
return false;
|
return false
|
||||||
},
|
},
|
||||||
resetIndex:
|
resetIndex:
|
||||||
() =>
|
() =>
|
||||||
({ editor }) => {
|
({ editor }) => {
|
||||||
editor.storage.searchAndReplace.resultIndex = 0;
|
editor.storage.searchAndReplace.resultIndex = 0
|
||||||
|
|
||||||
return false;
|
return false
|
||||||
},
|
},
|
||||||
nextSearchResult:
|
nextSearchResult:
|
||||||
() =>
|
() =>
|
||||||
({ editor }) => {
|
({ editor }) => {
|
||||||
const { results, resultIndex } = editor.storage.searchAndReplace;
|
const { results, resultIndex } = editor.storage.searchAndReplace
|
||||||
|
|
||||||
const nextIndex = resultIndex + 1;
|
const nextIndex = resultIndex + 1
|
||||||
|
|
||||||
if (results[nextIndex]) {
|
if (results[nextIndex]) {
|
||||||
editor.storage.searchAndReplace.resultIndex = nextIndex;
|
editor.storage.searchAndReplace.resultIndex = nextIndex
|
||||||
} else {
|
} else {
|
||||||
editor.storage.searchAndReplace.resultIndex = 0;
|
editor.storage.searchAndReplace.resultIndex = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false
|
||||||
},
|
},
|
||||||
previousSearchResult:
|
previousSearchResult:
|
||||||
() =>
|
() =>
|
||||||
({ editor }) => {
|
({ editor }) => {
|
||||||
const { results, resultIndex } = editor.storage.searchAndReplace;
|
const { results, resultIndex } = editor.storage.searchAndReplace
|
||||||
|
|
||||||
const prevIndex = resultIndex - 1;
|
const prevIndex = resultIndex - 1
|
||||||
|
|
||||||
if (results[prevIndex]) {
|
if (results[prevIndex]) {
|
||||||
editor.storage.searchAndReplace.resultIndex = prevIndex;
|
editor.storage.searchAndReplace.resultIndex = prevIndex
|
||||||
} else {
|
} else {
|
||||||
editor.storage.searchAndReplace.resultIndex = results.length - 1;
|
editor.storage.searchAndReplace.resultIndex = results.length - 1
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false
|
||||||
},
|
},
|
||||||
replace:
|
replace:
|
||||||
() =>
|
() =>
|
||||||
({ editor, state, dispatch }) => {
|
({ 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:
|
replaceAll:
|
||||||
() =>
|
() =>
|
||||||
({ editor, tr, dispatch }) => {
|
({ 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() {
|
addProseMirrorPlugins() {
|
||||||
const editor = this.editor;
|
const editor = this.editor
|
||||||
const { searchResultClass, disableRegex } = this.options;
|
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) =>
|
const setLastCaseSensitive = (t: boolean) =>
|
||||||
(editor.storage.searchAndReplace.lastCaseSensitive = t);
|
(editor.storage.searchAndReplace.lastCaseSensitive = t)
|
||||||
const setLastResultIndex = (t: number) => (editor.storage.searchAndReplace.lastResultIndex = t);
|
const setLastResultIndex = (t: number) => (editor.storage.searchAndReplace.lastResultIndex = t)
|
||||||
|
|
||||||
return [
|
return [
|
||||||
new Plugin({
|
new Plugin({
|
||||||
|
|
@ -364,7 +364,7 @@ export const SearchAndReplace = Extension.create<SearchAndReplaceOptions, Search
|
||||||
lastCaseSensitive,
|
lastCaseSensitive,
|
||||||
resultIndex,
|
resultIndex,
|
||||||
lastResultIndex
|
lastResultIndex
|
||||||
} = editor.storage.searchAndReplace;
|
} = editor.storage.searchAndReplace
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!docChanged &&
|
!docChanged &&
|
||||||
|
|
@ -372,15 +372,15 @@ export const SearchAndReplace = Extension.create<SearchAndReplaceOptions, Search
|
||||||
lastCaseSensitive === caseSensitive &&
|
lastCaseSensitive === caseSensitive &&
|
||||||
lastResultIndex === resultIndex
|
lastResultIndex === resultIndex
|
||||||
)
|
)
|
||||||
return oldState;
|
return oldState
|
||||||
|
|
||||||
setLastSearchTerm(searchTerm);
|
setLastSearchTerm(searchTerm)
|
||||||
setLastCaseSensitive(caseSensitive);
|
setLastCaseSensitive(caseSensitive)
|
||||||
setLastResultIndex(resultIndex);
|
setLastResultIndex(resultIndex)
|
||||||
|
|
||||||
if (!searchTerm) {
|
if (!searchTerm) {
|
||||||
editor.storage.searchAndReplace.results = [];
|
editor.storage.searchAndReplace.results = []
|
||||||
return DecorationSet.empty;
|
return DecorationSet.empty
|
||||||
}
|
}
|
||||||
|
|
||||||
const { decorationsToReturn, results } = processSearches(
|
const { decorationsToReturn, results } = processSearches(
|
||||||
|
|
@ -388,21 +388,21 @@ export const SearchAndReplace = Extension.create<SearchAndReplaceOptions, Search
|
||||||
getRegex(searchTerm, disableRegex, caseSensitive),
|
getRegex(searchTerm, disableRegex, caseSensitive),
|
||||||
searchResultClass,
|
searchResultClass,
|
||||||
resultIndex
|
resultIndex
|
||||||
);
|
)
|
||||||
|
|
||||||
editor.storage.searchAndReplace.results = results;
|
editor.storage.searchAndReplace.results = results
|
||||||
|
|
||||||
return decorationsToReturn;
|
return decorationsToReturn
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
decorations(state) {
|
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 { type Attributes, Extension } from '@tiptap/core'
|
||||||
import '@tiptap/extension-text-style';
|
import '@tiptap/extension-text-style'
|
||||||
|
|
||||||
declare module '@tiptap/core' {
|
declare module '@tiptap/core' {
|
||||||
interface Commands<ReturnType> {
|
interface Commands<ReturnType> {
|
||||||
fontSize: {
|
fontSize: {
|
||||||
setFontSize: (size: string) => ReturnType;
|
setFontSize: (size: string) => ReturnType
|
||||||
unsetFontSize: () => ReturnType;
|
unsetFontSize: () => ReturnType
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -16,7 +16,7 @@ export const FontSize = Extension.create({
|
||||||
addOptions() {
|
addOptions() {
|
||||||
return {
|
return {
|
||||||
types: ['textStyle']
|
types: ['textStyle']
|
||||||
};
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
addGlobalAttributes() {
|
addGlobalAttributes() {
|
||||||
|
|
@ -34,17 +34,17 @@ export const FontSize = Extension.create({
|
||||||
parseHTML: (element) => element.style.fontSize.replace(/['"]+/g, ''),
|
parseHTML: (element) => element.style.fontSize.replace(/['"]+/g, ''),
|
||||||
renderHTML: (attributes) => {
|
renderHTML: (attributes) => {
|
||||||
if (!attributes.fontSize) {
|
if (!attributes.fontSize) {
|
||||||
return {};
|
return {}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
style: `font-size: ${attributes.fontSize}`
|
style: `font-size: ${attributes.fontSize}`
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} as Attributes
|
} as Attributes
|
||||||
}
|
}
|
||||||
];
|
]
|
||||||
},
|
},
|
||||||
|
|
||||||
addCommands() {
|
addCommands() {
|
||||||
|
|
@ -57,8 +57,8 @@ export const FontSize = Extension.create({
|
||||||
() =>
|
() =>
|
||||||
({ chain }) =>
|
({ chain }) =>
|
||||||
chain().setMark('textStyle', { fontSize: null }).removeEmptyTextStyle().run()
|
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({
|
export const SmilieReplacer = Extension.create({
|
||||||
name: 'smilieReplacer',
|
name: 'smilieReplacer',
|
||||||
|
|
@ -128,6 +128,6 @@ export const SmilieReplacer = Extension.create({
|
||||||
textInputRule({ find: /:@ $/, replace: '😠 ' }),
|
textInputRule({ find: /:@ $/, replace: '😠 ' }),
|
||||||
textInputRule({ find: /<3 $/, replace: '❤️ ' }),
|
textInputRule({ find: /<3 $/, replace: '❤️ ' }),
|
||||||
textInputRule({ find: /\/shrug $/, replace: '¯\\_(ツ)_/¯' })
|
textInputRule({ find: /\/shrug $/, replace: '¯\\_(ツ)_/¯' })
|
||||||
];
|
]
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { SvelteNodeViewRenderer } from 'svelte-tiptap';
|
import { SvelteNodeViewRenderer } from 'svelte-tiptap'
|
||||||
import { Audio } from './AudioExtension.js';
|
import { Audio } from './AudioExtension.js'
|
||||||
import type { NodeViewProps } from '@tiptap/core';
|
import type { NodeViewProps } from '@tiptap/core'
|
||||||
import type { Component } from 'svelte';
|
import type { Component } from 'svelte'
|
||||||
|
|
||||||
export const AudioExtended = (content: Component<NodeViewProps>) =>
|
export const AudioExtended = (content: Component<NodeViewProps>) =>
|
||||||
Audio.extend({
|
Audio.extend({
|
||||||
|
|
@ -25,10 +25,10 @@ export const AudioExtended = (content: Component<NodeViewProps>) =>
|
||||||
align: {
|
align: {
|
||||||
default: 'left'
|
default: 'left'
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
addNodeView: () => {
|
addNodeView: () => {
|
||||||
return SvelteNodeViewRenderer(content);
|
return SvelteNodeViewRenderer(content)
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { Node, nodeInputRule } from '@tiptap/core';
|
import { Node, nodeInputRule } from '@tiptap/core'
|
||||||
import { Plugin, PluginKey } from '@tiptap/pm/state';
|
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
||||||
|
|
||||||
export interface AudioOptions {
|
export interface AudioOptions {
|
||||||
HTMLAttributes: Record<string, unknown>;
|
HTMLAttributes: Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '@tiptap/core' {
|
declare module '@tiptap/core' {
|
||||||
|
|
@ -11,20 +11,20 @@ declare module '@tiptap/core' {
|
||||||
/**
|
/**
|
||||||
* Set a audio node
|
* Set a audio node
|
||||||
*/
|
*/
|
||||||
setAudio: (src: string) => ReturnType;
|
setAudio: (src: string) => ReturnType
|
||||||
/**
|
/**
|
||||||
* Toggle a audio
|
* Toggle a audio
|
||||||
*/
|
*/
|
||||||
toggleAudio: (src: string) => ReturnType;
|
toggleAudio: (src: string) => ReturnType
|
||||||
/**
|
/**
|
||||||
* Remove a audio
|
* 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>({
|
export const Audio = Node.create<AudioOptions>({
|
||||||
name: 'audio',
|
name: 'audio',
|
||||||
|
|
@ -35,7 +35,7 @@ export const Audio = Node.create<AudioOptions>({
|
||||||
addOptions() {
|
addOptions() {
|
||||||
return {
|
return {
|
||||||
HTMLAttributes: {}
|
HTMLAttributes: {}
|
||||||
};
|
}
|
||||||
},
|
},
|
||||||
addAttributes() {
|
addAttributes() {
|
||||||
return {
|
return {
|
||||||
|
|
@ -44,7 +44,7 @@ export const Audio = Node.create<AudioOptions>({
|
||||||
parseHTML: (el) => (el as HTMLSpanElement).getAttribute('src'),
|
parseHTML: (el) => (el as HTMLSpanElement).getAttribute('src'),
|
||||||
renderHTML: (attrs) => ({ src: attrs.src })
|
renderHTML: (attrs) => ({ src: attrs.src })
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
},
|
},
|
||||||
parseHTML() {
|
parseHTML() {
|
||||||
return [
|
return [
|
||||||
|
|
@ -52,7 +52,7 @@ export const Audio = Node.create<AudioOptions>({
|
||||||
tag: 'audio',
|
tag: 'audio',
|
||||||
getAttrs: (el) => ({ src: (el as HTMLAudioElement).getAttribute('src') })
|
getAttrs: (el) => ({ src: (el as HTMLAudioElement).getAttribute('src') })
|
||||||
}
|
}
|
||||||
];
|
]
|
||||||
},
|
},
|
||||||
|
|
||||||
renderHTML({ HTMLAttributes }) {
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
|
@ -60,7 +60,7 @@ export const Audio = Node.create<AudioOptions>({
|
||||||
'audio',
|
'audio',
|
||||||
{ controls: 'true', style: 'width: 100%;', ...HTMLAttributes },
|
{ controls: 'true', style: 'width: 100%;', ...HTMLAttributes },
|
||||||
['source', HTMLAttributes]
|
['source', HTMLAttributes]
|
||||||
];
|
]
|
||||||
},
|
},
|
||||||
addCommands() {
|
addCommands() {
|
||||||
return {
|
return {
|
||||||
|
|
@ -79,7 +79,7 @@ export const Audio = Node.create<AudioOptions>({
|
||||||
() =>
|
() =>
|
||||||
({ commands }) =>
|
({ commands }) =>
|
||||||
commands.deleteNode(this.name)
|
commands.deleteNode(this.name)
|
||||||
};
|
}
|
||||||
},
|
},
|
||||||
addInputRules() {
|
addInputRules() {
|
||||||
return [
|
return [
|
||||||
|
|
@ -87,12 +87,12 @@ export const Audio = Node.create<AudioOptions>({
|
||||||
find: AUDIO_INPUT_REGEX,
|
find: AUDIO_INPUT_REGEX,
|
||||||
type: this.type,
|
type: this.type,
|
||||||
getAttributes: (match) => {
|
getAttributes: (match) => {
|
||||||
const [, , src] = match;
|
const [, , src] = match
|
||||||
|
|
||||||
return { src };
|
return { src }
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
];
|
]
|
||||||
},
|
},
|
||||||
addProseMirrorPlugins() {
|
addProseMirrorPlugins() {
|
||||||
return [
|
return [
|
||||||
|
|
@ -105,43 +105,43 @@ export const Audio = Node.create<AudioOptions>({
|
||||||
const {
|
const {
|
||||||
state: { schema, tr },
|
state: { schema, tr },
|
||||||
dispatch
|
dispatch
|
||||||
} = view;
|
} = view
|
||||||
const hasFiles =
|
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) =>
|
const audios = Array.from(event.dataTransfer.files).filter((file) =>
|
||||||
/audio/i.test(file.type)
|
/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) => {
|
audios.forEach((audio) => {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader()
|
||||||
|
|
||||||
reader.onload = (readerEvent) => {
|
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') {
|
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 { Editor, Node, mergeAttributes, type CommandProps, type NodeViewProps } from '@tiptap/core'
|
||||||
import type { Component } from 'svelte';
|
import type { Component } from 'svelte'
|
||||||
import { SvelteNodeViewRenderer } from 'svelte-tiptap';
|
import { SvelteNodeViewRenderer } from 'svelte-tiptap'
|
||||||
|
|
||||||
export interface AudioPlaceholderOptions {
|
export interface AudioPlaceholderOptions {
|
||||||
HTMLAttributes: Record<string, object>;
|
HTMLAttributes: Record<string, object>
|
||||||
onDrop: (files: File[], editor: Editor) => void;
|
onDrop: (files: File[], editor: Editor) => void
|
||||||
onDropRejected?: (files: File[], editor: Editor) => void;
|
onDropRejected?: (files: File[], editor: Editor) => void
|
||||||
onEmbed: (url: string, editor: Editor) => void;
|
onEmbed: (url: string, editor: Editor) => void
|
||||||
allowedMimeTypes?: Record<string, string[]>;
|
allowedMimeTypes?: Record<string, string[]>
|
||||||
maxFiles?: number;
|
maxFiles?: number
|
||||||
maxSize?: number;
|
maxSize?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '@tiptap/core' {
|
declare module '@tiptap/core' {
|
||||||
|
|
@ -18,8 +18,8 @@ declare module '@tiptap/core' {
|
||||||
/**
|
/**
|
||||||
* Inserts an audio placeholder
|
* Inserts an audio placeholder
|
||||||
*/
|
*/
|
||||||
insertAudioPlaceholder: () => ReturnType;
|
insertAudioPlaceholder: () => ReturnType
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -34,14 +34,14 @@ export const AudioPlaceholder = (
|
||||||
onDrop: () => {},
|
onDrop: () => {},
|
||||||
onDropRejected: () => {},
|
onDropRejected: () => {},
|
||||||
onEmbed: () => {}
|
onEmbed: () => {}
|
||||||
};
|
}
|
||||||
},
|
},
|
||||||
parseHTML() {
|
parseHTML() {
|
||||||
return [{ tag: `div[data-type="${this.name}"]` }];
|
return [{ tag: `div[data-type="${this.name}"]` }]
|
||||||
},
|
},
|
||||||
|
|
||||||
renderHTML({ HTMLAttributes }) {
|
renderHTML({ HTMLAttributes }) {
|
||||||
return ['div', mergeAttributes(HTMLAttributes)];
|
return ['div', mergeAttributes(HTMLAttributes)]
|
||||||
},
|
},
|
||||||
group: 'block',
|
group: 'block',
|
||||||
draggable: true,
|
draggable: true,
|
||||||
|
|
@ -50,15 +50,15 @@ export const AudioPlaceholder = (
|
||||||
isolating: true,
|
isolating: true,
|
||||||
|
|
||||||
addNodeView() {
|
addNodeView() {
|
||||||
return SvelteNodeViewRenderer(component);
|
return SvelteNodeViewRenderer(component)
|
||||||
},
|
},
|
||||||
addCommands() {
|
addCommands() {
|
||||||
return {
|
return {
|
||||||
insertAudioPlaceholder: () => (props: CommandProps) => {
|
insertAudioPlaceholder: () => (props: CommandProps) => {
|
||||||
return props.commands.insertContent({
|
return props.commands.insertContent({
|
||||||
type: 'audio-placeholder'
|
type: 'audio-placeholder'
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
};
|
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,27 @@
|
||||||
import { Slice } from '@tiptap/pm/model';
|
import { Slice } from '@tiptap/pm/model'
|
||||||
import { EditorView } from '@tiptap/pm/view';
|
import { EditorView } from '@tiptap/pm/view'
|
||||||
import * as pmView from '@tiptap/pm/view';
|
import * as pmView from '@tiptap/pm/view'
|
||||||
|
|
||||||
function getPmView() {
|
function getPmView() {
|
||||||
try {
|
try {
|
||||||
return pmView;
|
return pmView
|
||||||
} catch (error: Error) {
|
} catch (error: Error) {
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function serializeForClipboard(view: EditorView, slice: Slice) {
|
export function serializeForClipboard(view: EditorView, slice: Slice) {
|
||||||
// Newer Tiptap/ProseMirror
|
// Newer Tiptap/ProseMirror
|
||||||
if (view && typeof view.serializeForClipboard === 'function') {
|
if (view && typeof view.serializeForClipboard === 'function') {
|
||||||
return view.serializeForClipboard(slice);
|
return view.serializeForClipboard(slice)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Older version fallback
|
// Older version fallback
|
||||||
const proseMirrorView = getPmView();
|
const proseMirrorView = getPmView()
|
||||||
|
|
||||||
if (proseMirrorView && typeof proseMirrorView?.__serializeForClipboard === 'function') {
|
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 { Extension } from '@tiptap/core'
|
||||||
import { NodeSelection, Plugin, PluginKey, TextSelection } from '@tiptap/pm/state';
|
import { NodeSelection, Plugin, PluginKey, TextSelection } from '@tiptap/pm/state'
|
||||||
import { Fragment, Slice, Node } from '@tiptap/pm/model';
|
import { Fragment, Slice, Node } from '@tiptap/pm/model'
|
||||||
import { EditorView } from '@tiptap/pm/view';
|
import { EditorView } from '@tiptap/pm/view'
|
||||||
import { serializeForClipboard } from './ClipboardSerializer.js';
|
import { serializeForClipboard } from './ClipboardSerializer.js'
|
||||||
|
|
||||||
export interface GlobalDragHandleOptions {
|
export interface GlobalDragHandleOptions {
|
||||||
/**
|
/**
|
||||||
* The width of the drag handle
|
* The width of the drag handle
|
||||||
*/
|
*/
|
||||||
dragHandleWidth: number;
|
dragHandleWidth: number
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The treshold for scrolling
|
* The treshold for scrolling
|
||||||
*/
|
*/
|
||||||
scrollTreshold: number;
|
scrollTreshold: number
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* The css selector to query for the drag handle. (eg: '.custom-handle').
|
* 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
|
* 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
|
* Tags to be excluded for drag handle
|
||||||
*/
|
*/
|
||||||
excludedTags: string[];
|
excludedTags: string[]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom nodes to be included for drag handle
|
* Custom nodes to be included for drag handle
|
||||||
*/
|
*/
|
||||||
customNodes: string[];
|
customNodes: string[]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* onNodeChange callback for drag handle
|
* onNodeChange callback for drag handle
|
||||||
* @param data
|
* @param data
|
||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
onMouseMove?: (data: { node: Node; pos: number }) => void;
|
onMouseMove?: (data: { node: Node; pos: number }) => void
|
||||||
}
|
}
|
||||||
function absoluteRect(node: Element) {
|
function absoluteRect(node: Element) {
|
||||||
const data = node.getBoundingClientRect();
|
const data = node.getBoundingClientRect()
|
||||||
const modal = node.closest('[role="dialog"]');
|
const modal = node.closest('[role="dialog"]')
|
||||||
|
|
||||||
if (modal && window.getComputedStyle(modal).transform !== 'none') {
|
if (modal && window.getComputedStyle(modal).transform !== 'none') {
|
||||||
const modalRect = modal.getBoundingClientRect();
|
const modalRect = modal.getBoundingClientRect()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
top: data.top - modalRect.top,
|
top: data.top - modalRect.top,
|
||||||
left: data.left - modalRect.left,
|
left: data.left - modalRect.left,
|
||||||
width: data.width
|
width: data.width
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
top: data.top,
|
top: data.top,
|
||||||
left: data.left,
|
left: data.left,
|
||||||
width: data.width
|
width: data.width
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function nodeDOMAtCoords(coords: { x: number; y: number }, options: GlobalDragHandleOptions) {
|
function nodeDOMAtCoords(coords: { x: number; y: number }, options: GlobalDragHandleOptions) {
|
||||||
|
|
@ -71,34 +71,34 @@ function nodeDOMAtCoords(coords: { x: number; y: number }, options: GlobalDragHa
|
||||||
'h5',
|
'h5',
|
||||||
'h6',
|
'h6',
|
||||||
...options.customNodes.map((node) => `[data-type=${node}]`)
|
...options.customNodes.map((node) => `[data-type=${node}]`)
|
||||||
].join(', ');
|
].join(', ')
|
||||||
return document
|
return document
|
||||||
.elementsFromPoint(coords.x, coords.y)
|
.elementsFromPoint(coords.x, coords.y)
|
||||||
.find(
|
.find(
|
||||||
(elem: Element) => elem.parentElement?.matches?.('.ProseMirror') || elem.matches(selectors)
|
(elem: Element) => elem.parentElement?.matches?.('.ProseMirror') || elem.matches(selectors)
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
function nodePosAtDOM(node: Element, view: EditorView, options: GlobalDragHandleOptions) {
|
function nodePosAtDOM(node: Element, view: EditorView, options: GlobalDragHandleOptions) {
|
||||||
const boundingRect = node.getBoundingClientRect();
|
const boundingRect = node.getBoundingClientRect()
|
||||||
|
|
||||||
return view.posAtCoords({
|
return view.posAtCoords({
|
||||||
left: boundingRect.left + 50 + options.dragHandleWidth,
|
left: boundingRect.left + 50 + options.dragHandleWidth,
|
||||||
top: boundingRect.top + 1
|
top: boundingRect.top + 1
|
||||||
})?.inside;
|
})?.inside
|
||||||
}
|
}
|
||||||
|
|
||||||
function calcNodePos(pos: number, view: EditorView) {
|
function calcNodePos(pos: number, view: EditorView) {
|
||||||
const $pos = view.state.doc.resolve(pos);
|
const $pos = view.state.doc.resolve(pos)
|
||||||
if ($pos.depth > 1) return $pos.before($pos.depth);
|
if ($pos.depth > 1) return $pos.before($pos.depth)
|
||||||
return pos;
|
return pos
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DragHandlePlugin(options: GlobalDragHandleOptions & { pluginKey: string }) {
|
export function DragHandlePlugin(options: GlobalDragHandleOptions & { pluginKey: string }) {
|
||||||
let listType = '';
|
let listType = ''
|
||||||
function handleDragStart(event: DragEvent, view: EditorView) {
|
function handleDragStart(event: DragEvent, view: EditorView) {
|
||||||
view.focus();
|
view.focus()
|
||||||
|
|
||||||
if (!event.dataTransfer) return;
|
if (!event.dataTransfer) return
|
||||||
|
|
||||||
const node = nodeDOMAtCoords(
|
const node = nodeDOMAtCoords(
|
||||||
{
|
{
|
||||||
|
|
@ -106,38 +106,38 @@ export function DragHandlePlugin(options: GlobalDragHandleOptions & { pluginKey:
|
||||||
y: event.clientY
|
y: event.clientY
|
||||||
},
|
},
|
||||||
options
|
options
|
||||||
);
|
)
|
||||||
|
|
||||||
if (!(node instanceof Element)) return;
|
if (!(node instanceof Element)) return
|
||||||
|
|
||||||
let draggedNodePos = nodePosAtDOM(node, view, options);
|
let draggedNodePos = nodePosAtDOM(node, view, options)
|
||||||
if (draggedNodePos == null || draggedNodePos < 0) return;
|
if (draggedNodePos == null || draggedNodePos < 0) return
|
||||||
draggedNodePos = calcNodePos(draggedNodePos, view);
|
draggedNodePos = calcNodePos(draggedNodePos, view)
|
||||||
|
|
||||||
const { from, to } = view.state.selection;
|
const { from, to } = view.state.selection
|
||||||
const diff = from - to;
|
const diff = from - to
|
||||||
|
|
||||||
const fromSelectionPos = calcNodePos(from, view);
|
const fromSelectionPos = calcNodePos(from, view)
|
||||||
let differentNodeSelected = false;
|
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
|
// 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 {
|
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
|
// Check if the node where the drag event started is part of the current selection
|
||||||
differentNodeSelected = !(
|
differentNodeSelected = !(
|
||||||
draggedNodePos + 1 >= nodeSelection.$from.pos && draggedNodePos <= nodeSelection.$to.pos
|
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)) {
|
if (!differentNodeSelected && diff !== 0 && !(view.state.selection instanceof NodeSelection)) {
|
||||||
const endSelection = NodeSelection.create(view.state.doc, to - 1);
|
const endSelection = NodeSelection.create(view.state.doc, to - 1)
|
||||||
selection = TextSelection.create(view.state.doc, draggedNodePos, endSelection.$to.pos);
|
selection = TextSelection.create(view.state.doc, draggedNodePos, endSelection.$to.pos)
|
||||||
} else {
|
} 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 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
|
// 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.isInline ||
|
||||||
(selection as NodeSelection).node.type.name === 'tableRow'
|
(selection as NodeSelection).node.type.name === 'tableRow'
|
||||||
) {
|
) {
|
||||||
const $pos = view.state.doc.resolve(selection.from);
|
const $pos = view.state.doc.resolve(selection.from)
|
||||||
selection = NodeSelection.create(view.state.doc, $pos.before());
|
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 the selected node is a list item, we need to save the type of the wrapping list e.g. OL or UL
|
||||||
if (
|
if (
|
||||||
view.state.selection instanceof NodeSelection &&
|
view.state.selection instanceof NodeSelection &&
|
||||||
view.state.selection.node.type.name === 'listItem'
|
view.state.selection.node.type.name === 'listItem'
|
||||||
) {
|
) {
|
||||||
listType = node.parentElement!.tagName;
|
listType = node.parentElement!.tagName
|
||||||
}
|
}
|
||||||
|
|
||||||
const slice = view.state.selection.content();
|
const slice = view.state.selection.content()
|
||||||
const { dom, text } = serializeForClipboard(view, slice);
|
const { dom, text } = serializeForClipboard(view, slice)
|
||||||
|
|
||||||
event.dataTransfer.clearData();
|
event.dataTransfer.clearData()
|
||||||
event.dataTransfer.setData('text/html', dom.innerHTML);
|
event.dataTransfer.setData('text/html', dom.innerHTML)
|
||||||
event.dataTransfer.setData('text/plain', text);
|
event.dataTransfer.setData('text/plain', text)
|
||||||
event.dataTransfer.effectAllowed = 'copyMove';
|
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() {
|
function hideDragHandle() {
|
||||||
if (dragHandleElement) {
|
if (dragHandleElement) {
|
||||||
dragHandleElement.classList.add('hide');
|
dragHandleElement.classList.add('hide')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function showDragHandle() {
|
function showDragHandle() {
|
||||||
if (dragHandleElement) {
|
if (dragHandleElement) {
|
||||||
dragHandleElement.classList.remove('hide');
|
dragHandleElement.classList.remove('hide')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function hideHandleOnEditorOut(event: MouseEvent) {
|
function hideHandleOnEditorOut(event: MouseEvent) {
|
||||||
if (event.target instanceof Element) {
|
if (event.target instanceof Element) {
|
||||||
// Check if the relatedTarget class is still inside the editor
|
// Check if the relatedTarget class is still inside the editor
|
||||||
const relatedTarget = event.relatedTarget as HTMLElement;
|
const relatedTarget = event.relatedTarget as HTMLElement
|
||||||
const isInsideEditor =
|
const isInsideEditor =
|
||||||
relatedTarget?.classList.contains('tiptap') ||
|
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({
|
return new Plugin({
|
||||||
|
|
@ -204,54 +204,54 @@ export function DragHandlePlugin(options: GlobalDragHandleOptions & { pluginKey:
|
||||||
view: (view) => {
|
view: (view) => {
|
||||||
const handleBySelector = options.dragHandleSelector
|
const handleBySelector = options.dragHandleSelector
|
||||||
? document.querySelector<HTMLElement>(options.dragHandleSelector)
|
? document.querySelector<HTMLElement>(options.dragHandleSelector)
|
||||||
: null;
|
: null
|
||||||
dragHandleElement = handleBySelector ?? document.createElement('div');
|
dragHandleElement = handleBySelector ?? document.createElement('div')
|
||||||
dragHandleElement.draggable = true;
|
dragHandleElement.draggable = true
|
||||||
dragHandleElement.dataset.dragHandle = '';
|
dragHandleElement.dataset.dragHandle = ''
|
||||||
dragHandleElement.classList.add('drag-handle');
|
dragHandleElement.classList.add('drag-handle')
|
||||||
|
|
||||||
function onDragHandleDragStart(e: DragEvent) {
|
function onDragHandleDragStart(e: DragEvent) {
|
||||||
handleDragStart(e, view);
|
handleDragStart(e, view)
|
||||||
}
|
}
|
||||||
|
|
||||||
dragHandleElement.addEventListener('dragstart', onDragHandleDragStart);
|
dragHandleElement.addEventListener('dragstart', onDragHandleDragStart)
|
||||||
|
|
||||||
function onDragHandleDrag(e: DragEvent) {
|
function onDragHandleDrag(e: DragEvent) {
|
||||||
hideDragHandle();
|
hideDragHandle()
|
||||||
const scrollY = window.scrollY;
|
const scrollY = window.scrollY
|
||||||
if (e.clientY < options.scrollTreshold) {
|
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) {
|
} 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) {
|
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 {
|
return {
|
||||||
destroy: () => {
|
destroy: () => {
|
||||||
if (!handleBySelector) {
|
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: {
|
props: {
|
||||||
handleDOMEvents: {
|
handleDOMEvents: {
|
||||||
mousemove: (view, event) => {
|
mousemove: (view, event) => {
|
||||||
if (!view.editable) {
|
if (!view.editable) {
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const node = nodeDOMAtCoords(
|
const node = nodeDOMAtCoords(
|
||||||
|
|
@ -260,76 +260,76 @@ export function DragHandlePlugin(options: GlobalDragHandleOptions & { pluginKey:
|
||||||
y: event.clientY
|
y: event.clientY
|
||||||
},
|
},
|
||||||
options
|
options
|
||||||
);
|
)
|
||||||
|
|
||||||
const notDragging = node?.closest('.not-draggable');
|
const notDragging = node?.closest('.not-draggable')
|
||||||
const excludedTagList = options.excludedTags.concat(['ol', 'ul']).join(', ');
|
const excludedTagList = options.excludedTags.concat(['ol', 'ul']).join(', ')
|
||||||
|
|
||||||
if (!(node instanceof Element) || node.matches(excludedTagList) || notDragging) {
|
if (!(node instanceof Element) || node.matches(excludedTagList) || notDragging) {
|
||||||
hideDragHandle();
|
hideDragHandle()
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const nodePos = nodePosAtDOM(node, view, options);
|
const nodePos = nodePosAtDOM(node, view, options)
|
||||||
if (nodePos !== undefined) {
|
if (nodePos !== undefined) {
|
||||||
const currentNode = view.state.doc.nodeAt(nodePos);
|
const currentNode = view.state.doc.nodeAt(nodePos)
|
||||||
if (currentNode !== null) {
|
if (currentNode !== null) {
|
||||||
options.onMouseMove?.({ node: currentNode, pos: nodePos });
|
options.onMouseMove?.({ node: currentNode, pos: nodePos })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const compStyle = window.getComputedStyle(node);
|
const compStyle = window.getComputedStyle(node)
|
||||||
const parsedLineHeight = parseInt(compStyle.lineHeight, 10);
|
const parsedLineHeight = parseInt(compStyle.lineHeight, 10)
|
||||||
const lineHeight = isNaN(parsedLineHeight)
|
const lineHeight = isNaN(parsedLineHeight)
|
||||||
? parseInt(compStyle.fontSize) * 1.2
|
? parseInt(compStyle.fontSize) * 1.2
|
||||||
: parsedLineHeight;
|
: parsedLineHeight
|
||||||
const paddingTop = parseInt(compStyle.paddingTop, 10);
|
const paddingTop = parseInt(compStyle.paddingTop, 10)
|
||||||
|
|
||||||
const rect = absoluteRect(node);
|
const rect = absoluteRect(node)
|
||||||
|
|
||||||
rect.top += (lineHeight - 24) / 2;
|
rect.top += (lineHeight - 24) / 2
|
||||||
rect.top += paddingTop;
|
rect.top += paddingTop
|
||||||
// Li markers
|
// Li markers
|
||||||
if (node.matches('ul:not([data-type=taskList]) li, ol li')) {
|
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.left = `${rect.left - rect.width}px`
|
||||||
dragHandleElement.style.top = `${rect.top}px`;
|
dragHandleElement.style.top = `${rect.top}px`
|
||||||
showDragHandle();
|
showDragHandle()
|
||||||
},
|
},
|
||||||
keydown: () => {
|
keydown: () => {
|
||||||
hideDragHandle();
|
hideDragHandle()
|
||||||
},
|
},
|
||||||
mousewheel: () => {
|
mousewheel: () => {
|
||||||
hideDragHandle();
|
hideDragHandle()
|
||||||
},
|
},
|
||||||
// dragging class is used for CSS
|
// dragging class is used for CSS
|
||||||
dragstart: (view) => {
|
dragstart: (view) => {
|
||||||
view.dom.classList.add('dragging');
|
view.dom.classList.add('dragging')
|
||||||
},
|
},
|
||||||
drop: (view, event) => {
|
drop: (view, event) => {
|
||||||
view.dom.classList.remove('dragging');
|
view.dom.classList.remove('dragging')
|
||||||
hideDragHandle();
|
hideDragHandle()
|
||||||
let droppedNode: Node | null = null;
|
let droppedNode: Node | null = null
|
||||||
const dropPos = view.posAtCoords({
|
const dropPos = view.posAtCoords({
|
||||||
left: event.clientX,
|
left: event.clientX,
|
||||||
top: event.clientY
|
top: event.clientY
|
||||||
});
|
})
|
||||||
|
|
||||||
if (!dropPos) return;
|
if (!dropPos) return
|
||||||
|
|
||||||
if (view.state.selection instanceof NodeSelection) {
|
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 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 (
|
if (
|
||||||
|
|
@ -338,17 +338,17 @@ export function DragHandlePlugin(options: GlobalDragHandleOptions & { pluginKey:
|
||||||
!isDroppedInsideList &&
|
!isDroppedInsideList &&
|
||||||
listType == 'OL'
|
listType == 'OL'
|
||||||
) {
|
) {
|
||||||
const newList = view.state.schema.nodes.orderedList?.createAndFill(null, droppedNode);
|
const newList = view.state.schema.nodes.orderedList?.createAndFill(null, droppedNode)
|
||||||
const slice = new Slice(Fragment.from(newList), 0, 0);
|
const slice = new Slice(Fragment.from(newList), 0, 0)
|
||||||
view.dragging = { slice, move: event.ctrlKey };
|
view.dragging = { slice, move: event.ctrlKey }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
dragend: (view) => {
|
dragend: (view) => {
|
||||||
view.dom.classList.remove('dragging');
|
view.dom.classList.remove('dragging')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const GlobalDragHandle = Extension.create({
|
const GlobalDragHandle = Extension.create({
|
||||||
|
|
@ -360,7 +360,7 @@ const GlobalDragHandle = Extension.create({
|
||||||
scrollTreshold: 100,
|
scrollTreshold: 100,
|
||||||
excludedTags: [],
|
excludedTags: [],
|
||||||
customNodes: []
|
customNodes: []
|
||||||
};
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
addProseMirrorPlugins() {
|
addProseMirrorPlugins() {
|
||||||
|
|
@ -374,8 +374,8 @@ const GlobalDragHandle = Extension.create({
|
||||||
customNodes: this.options.customNodes,
|
customNodes: this.options.customNodes,
|
||||||
onMouseMove: this.options.onMouseMove
|
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 {
|
export interface IframeOptions {
|
||||||
allowFullscreen: boolean;
|
allowFullscreen: boolean
|
||||||
HTMLAttributes: {
|
HTMLAttributes: {
|
||||||
[key: string]: unknown;
|
[key: string]: unknown
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '@tiptap/core' {
|
declare module '@tiptap/core' {
|
||||||
|
|
@ -13,9 +13,9 @@ declare module '@tiptap/core' {
|
||||||
/**
|
/**
|
||||||
* Add an iframe with src
|
* Add an iframe with src
|
||||||
*/
|
*/
|
||||||
setIframe: (options: { src: string }) => ReturnType;
|
setIframe: (options: { src: string }) => ReturnType
|
||||||
removeIframe: () => ReturnType;
|
removeIframe: () => ReturnType
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -32,7 +32,7 @@ export default Node.create<IframeOptions>({
|
||||||
HTMLAttributes: {
|
HTMLAttributes: {
|
||||||
class: 'iframe-wrapper'
|
class: 'iframe-wrapper'
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
addAttributes() {
|
addAttributes() {
|
||||||
|
|
@ -47,7 +47,7 @@ export default Node.create<IframeOptions>({
|
||||||
default: this.options.allowFullscreen,
|
default: this.options.allowFullscreen,
|
||||||
parseHTML: () => this.options.allowFullscreen
|
parseHTML: () => this.options.allowFullscreen
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
parseHTML() {
|
parseHTML() {
|
||||||
|
|
@ -55,11 +55,11 @@ export default Node.create<IframeOptions>({
|
||||||
{
|
{
|
||||||
tag: 'iframe'
|
tag: 'iframe'
|
||||||
}
|
}
|
||||||
];
|
]
|
||||||
},
|
},
|
||||||
|
|
||||||
renderHTML({ HTMLAttributes }) {
|
renderHTML({ HTMLAttributes }) {
|
||||||
return ['div', this.options.HTMLAttributes, ['iframe', HTMLAttributes]];
|
return ['div', this.options.HTMLAttributes, ['iframe', HTMLAttributes]]
|
||||||
},
|
},
|
||||||
|
|
||||||
addCommands() {
|
addCommands() {
|
||||||
|
|
@ -67,19 +67,19 @@ export default Node.create<IframeOptions>({
|
||||||
setIframe:
|
setIframe:
|
||||||
(options: { src: string }) =>
|
(options: { src: string }) =>
|
||||||
({ tr, dispatch }) => {
|
({ tr, dispatch }) => {
|
||||||
const { selection } = tr;
|
const { selection } = tr
|
||||||
const node = this.type.create(options);
|
const node = this.type.create(options)
|
||||||
|
|
||||||
if (dispatch) {
|
if (dispatch) {
|
||||||
tr.replaceRangeWith(selection.from, selection.to, node);
|
tr.replaceRangeWith(selection.from, selection.to, node)
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true
|
||||||
},
|
},
|
||||||
removeIframe:
|
removeIframe:
|
||||||
() =>
|
() =>
|
||||||
({ commands }) =>
|
({ commands }) =>
|
||||||
commands.deleteNode(this.name)
|
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 { NodeViewProps } from '@tiptap/core'
|
||||||
import type { Component } from 'svelte';
|
import type { Component } from 'svelte'
|
||||||
import IFrame from './IFrame.js';
|
import IFrame from './IFrame.js'
|
||||||
|
|
||||||
export const IFrameExtended = (content: Component<NodeViewProps>) =>
|
export const IFrameExtended = (content: Component<NodeViewProps>) =>
|
||||||
IFrame.extend({
|
IFrame.extend({
|
||||||
|
|
@ -26,10 +26,10 @@ export const IFrameExtended = (content: Component<NodeViewProps>) =>
|
||||||
align: {
|
align: {
|
||||||
default: 'left'
|
default: 'left'
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
addNodeView: () => {
|
addNodeView: () => {
|
||||||
return SvelteNodeViewRenderer(content);
|
return SvelteNodeViewRenderer(content)
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { Node, mergeAttributes, type CommandProps, type NodeViewProps } from '@tiptap/core';
|
import { Node, mergeAttributes, type CommandProps, type NodeViewProps } from '@tiptap/core'
|
||||||
import type { Component } from 'svelte';
|
import type { Component } from 'svelte'
|
||||||
import { SvelteNodeViewRenderer } from 'svelte-tiptap';
|
import { SvelteNodeViewRenderer } from 'svelte-tiptap'
|
||||||
|
|
||||||
export interface IFramePlaceholderOptions {
|
export interface IFramePlaceholderOptions {
|
||||||
HTMLAttributes: Record<string, object>;
|
HTMLAttributes: Record<string, object>
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '@tiptap/core' {
|
declare module '@tiptap/core' {
|
||||||
|
|
@ -12,8 +12,8 @@ declare module '@tiptap/core' {
|
||||||
/**
|
/**
|
||||||
* Inserts a IFrame placeholder
|
* Inserts a IFrame placeholder
|
||||||
*/
|
*/
|
||||||
insertIFramePlaceholder: () => ReturnType;
|
insertIFramePlaceholder: () => ReturnType
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -26,14 +26,14 @@ export const IFramePlaceholder = (content: Component<NodeViewProps>) =>
|
||||||
onDrop: () => {},
|
onDrop: () => {},
|
||||||
onDropRejected: () => {},
|
onDropRejected: () => {},
|
||||||
onEmbed: () => {}
|
onEmbed: () => {}
|
||||||
};
|
}
|
||||||
},
|
},
|
||||||
parseHTML() {
|
parseHTML() {
|
||||||
return [{ tag: `div[data-type="${this.name}"]` }];
|
return [{ tag: `div[data-type="${this.name}"]` }]
|
||||||
},
|
},
|
||||||
|
|
||||||
renderHTML({ HTMLAttributes }) {
|
renderHTML({ HTMLAttributes }) {
|
||||||
return ['div', mergeAttributes(HTMLAttributes)];
|
return ['div', mergeAttributes(HTMLAttributes)]
|
||||||
},
|
},
|
||||||
group: 'block',
|
group: 'block',
|
||||||
draggable: true,
|
draggable: true,
|
||||||
|
|
@ -42,15 +42,15 @@ export const IFramePlaceholder = (content: Component<NodeViewProps>) =>
|
||||||
isolating: true,
|
isolating: true,
|
||||||
|
|
||||||
addNodeView() {
|
addNodeView() {
|
||||||
return SvelteNodeViewRenderer(content);
|
return SvelteNodeViewRenderer(content)
|
||||||
},
|
},
|
||||||
addCommands() {
|
addCommands() {
|
||||||
return {
|
return {
|
||||||
insertIFramePlaceholder: () => (props: CommandProps) => {
|
insertIFramePlaceholder: () => (props: CommandProps) => {
|
||||||
return props.commands.insertContent({
|
return props.commands.insertContent({
|
||||||
type: 'iframe-placeholder'
|
type: 'iframe-placeholder'
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
};
|
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { SvelteNodeViewRenderer } from 'svelte-tiptap';
|
import { SvelteNodeViewRenderer } from 'svelte-tiptap'
|
||||||
import Image, { type ImageOptions } from '@tiptap/extension-image';
|
import Image, { type ImageOptions } from '@tiptap/extension-image'
|
||||||
import type { Component } from 'svelte';
|
import type { Component } from 'svelte'
|
||||||
import type { NodeViewProps, Node } from '@tiptap/core';
|
import type { NodeViewProps, Node } from '@tiptap/core'
|
||||||
|
|
||||||
export const ImageExtended = (component: Component<NodeViewProps>): Node<ImageOptions, unknown> => {
|
export const ImageExtended = (component: Component<NodeViewProps>): Node<ImageOptions, unknown> => {
|
||||||
return Image.extend({
|
return Image.extend({
|
||||||
|
|
@ -25,12 +25,12 @@ export const ImageExtended = (component: Component<NodeViewProps>): Node<ImageOp
|
||||||
align: {
|
align: {
|
||||||
default: 'left'
|
default: 'left'
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
},
|
},
|
||||||
addNodeView: () => {
|
addNodeView: () => {
|
||||||
return SvelteNodeViewRenderer(component);
|
return SvelteNodeViewRenderer(component)
|
||||||
}
|
}
|
||||||
}).configure({
|
}).configure({
|
||||||
allowBase64: true
|
allowBase64: true
|
||||||
});
|
})
|
||||||
};
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,15 @@
|
||||||
import { Editor, Node, mergeAttributes, type CommandProps, type NodeViewProps } from '@tiptap/core';
|
import { Editor, Node, mergeAttributes, type CommandProps, type NodeViewProps } from '@tiptap/core'
|
||||||
import type { Component } from 'svelte';
|
import type { Component } from 'svelte'
|
||||||
import { SvelteNodeViewRenderer } from 'svelte-tiptap';
|
import { SvelteNodeViewRenderer } from 'svelte-tiptap'
|
||||||
|
|
||||||
export interface ImagePlaceholderOptions {
|
export interface ImagePlaceholderOptions {
|
||||||
HTMLAttributes: Record<string, object>;
|
HTMLAttributes: Record<string, object>
|
||||||
onDrop: (files: File[], editor: Editor) => void;
|
onDrop: (files: File[], editor: Editor) => void
|
||||||
onDropRejected?: (files: File[], editor: Editor) => void;
|
onDropRejected?: (files: File[], editor: Editor) => void
|
||||||
onEmbed: (url: string, editor: Editor) => void;
|
onEmbed: (url: string, editor: Editor) => void
|
||||||
allowedMimeTypes?: Record<string, string[]>;
|
allowedMimeTypes?: Record<string, string[]>
|
||||||
maxFiles?: number;
|
maxFiles?: number
|
||||||
maxSize?: number;
|
maxSize?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '@tiptap/core' {
|
declare module '@tiptap/core' {
|
||||||
|
|
@ -18,8 +18,8 @@ declare module '@tiptap/core' {
|
||||||
/**
|
/**
|
||||||
* Inserts an image placeholder
|
* Inserts an image placeholder
|
||||||
*/
|
*/
|
||||||
insertImagePlaceholder: () => ReturnType;
|
insertImagePlaceholder: () => ReturnType
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -34,14 +34,14 @@ export const ImagePlaceholder = (
|
||||||
onDrop: () => {},
|
onDrop: () => {},
|
||||||
onDropRejected: () => {},
|
onDropRejected: () => {},
|
||||||
onEmbed: () => {}
|
onEmbed: () => {}
|
||||||
};
|
}
|
||||||
},
|
},
|
||||||
parseHTML() {
|
parseHTML() {
|
||||||
return [{ tag: `div[data-type="${this.name}"]` }];
|
return [{ tag: `div[data-type="${this.name}"]` }]
|
||||||
},
|
},
|
||||||
|
|
||||||
renderHTML({ HTMLAttributes }) {
|
renderHTML({ HTMLAttributes }) {
|
||||||
return ['div', mergeAttributes(HTMLAttributes)];
|
return ['div', mergeAttributes(HTMLAttributes)]
|
||||||
},
|
},
|
||||||
group: 'block',
|
group: 'block',
|
||||||
draggable: true,
|
draggable: true,
|
||||||
|
|
@ -50,15 +50,15 @@ export const ImagePlaceholder = (
|
||||||
isolating: true,
|
isolating: true,
|
||||||
|
|
||||||
addNodeView() {
|
addNodeView() {
|
||||||
return SvelteNodeViewRenderer(component);
|
return SvelteNodeViewRenderer(component)
|
||||||
},
|
},
|
||||||
addCommands() {
|
addCommands() {
|
||||||
return {
|
return {
|
||||||
insertImagePlaceholder: () => (props: CommandProps) => {
|
insertImagePlaceholder: () => (props: CommandProps) => {
|
||||||
return props.commands.insertContent({
|
return props.commands.insertContent({
|
||||||
type: 'image-placeholder'
|
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 { EdraCommand } from '../../commands/types.js'
|
||||||
import type { Editor } from '@tiptap/core';
|
import type { Editor } from '@tiptap/core'
|
||||||
|
|
||||||
export interface Group {
|
export interface Group {
|
||||||
name: string;
|
name: string
|
||||||
title: string;
|
title: string
|
||||||
commands: EdraCommand[];
|
commands: EdraCommand[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GROUPS: Group[] = [
|
export const GROUPS: Group[] = [
|
||||||
|
|
@ -20,7 +20,7 @@ export const GROUPS: Group[] = [
|
||||||
name: 'blockquote',
|
name: 'blockquote',
|
||||||
label: 'Blockquote',
|
label: 'Blockquote',
|
||||||
action: (editor: Editor) => {
|
action: (editor: Editor) => {
|
||||||
editor.chain().focus().setBlockquote().run();
|
editor.chain().focus().setBlockquote().run()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -28,7 +28,7 @@ export const GROUPS: Group[] = [
|
||||||
name: 'codeBlock',
|
name: 'codeBlock',
|
||||||
label: 'Code Block',
|
label: 'Code Block',
|
||||||
action: (editor: Editor) => {
|
action: (editor: Editor) => {
|
||||||
editor.chain().focus().setCodeBlock().run();
|
editor.chain().focus().setCodeBlock().run()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
...commands.lists.commands
|
...commands.lists.commands
|
||||||
|
|
@ -45,11 +45,11 @@ export const GROUPS: Group[] = [
|
||||||
name: 'horizontalRule',
|
name: 'horizontalRule',
|
||||||
label: 'Horizontal Rule',
|
label: 'Horizontal Rule',
|
||||||
action: (editor: Editor) => {
|
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 { Editor, Extension } from '@tiptap/core'
|
||||||
import Suggestion, { type SuggestionProps, type SuggestionKeyDownProps } from '@tiptap/suggestion';
|
import Suggestion, { type SuggestionProps, type SuggestionKeyDownProps } from '@tiptap/suggestion'
|
||||||
import { PluginKey } from '@tiptap/pm/state';
|
import { PluginKey } from '@tiptap/pm/state'
|
||||||
|
|
||||||
import { GROUPS } from './groups.js';
|
import { GROUPS } from './groups.js'
|
||||||
import SvelteRenderer from '../../svelte-renderer.js';
|
import SvelteRenderer from '../../svelte-renderer.js'
|
||||||
import tippy from 'tippy.js';
|
import tippy from 'tippy.js'
|
||||||
import type { Component } from 'svelte';
|
import type { Component } from 'svelte'
|
||||||
|
|
||||||
const extensionName = 'slashCommand';
|
const extensionName = 'slashCommand'
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
let popup: any;
|
let popup: any
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
export default (menuList: Component<any, any, ''>): Extension =>
|
export default (menuList: Component<any, any, ''>): Extension =>
|
||||||
|
|
@ -36,7 +36,7 @@ export default (menuList: Component<any, any, ''>): Extension =>
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
addProseMirrorPlugins() {
|
addProseMirrorPlugins() {
|
||||||
|
|
@ -47,53 +47,53 @@ export default (menuList: Component<any, any, ''>): Extension =>
|
||||||
allowSpaces: true,
|
allowSpaces: true,
|
||||||
pluginKey: new PluginKey(extensionName),
|
pluginKey: new PluginKey(extensionName),
|
||||||
allow: ({ state, range }) => {
|
allow: ({ state, range }) => {
|
||||||
const $from = state.doc.resolve(range.from);
|
const $from = state.doc.resolve(range.from)
|
||||||
const afterContent = $from.parent.textContent?.substring(
|
const afterContent = $from.parent.textContent?.substring(
|
||||||
$from.parent.textContent?.indexOf('/')
|
$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
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
command: ({ editor, props }: { editor: Editor; props: any }) => {
|
command: ({ editor, props }: { editor: Editor; props: any }) => {
|
||||||
const { view, state } = editor;
|
const { view, state } = editor
|
||||||
const { $head, $from } = view.state.selection;
|
const { $head, $from } = view.state.selection
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const end = $from.pos;
|
const end = $from.pos
|
||||||
const from = $head?.nodeBefore
|
const from = $head?.nodeBefore
|
||||||
? end -
|
? end -
|
||||||
($head.nodeBefore.text?.substring($head.nodeBefore.text?.indexOf('/')).length ??
|
($head.nodeBefore.text?.substring($head.nodeBefore.text?.indexOf('/')).length ??
|
||||||
0)
|
0)
|
||||||
: $from.start();
|
: $from.start()
|
||||||
|
|
||||||
const tr = state.tr.deleteRange(from, end);
|
const tr = state.tr.deleteRange(from, end)
|
||||||
view.dispatch(tr);
|
view.dispatch(tr)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
props.action(editor);
|
props.action(editor)
|
||||||
view.focus();
|
view.focus()
|
||||||
},
|
},
|
||||||
items: ({ query }: { query: string }) => {
|
items: ({ query }: { query: string }) => {
|
||||||
const withFilteredCommands = GROUPS.map((group) => ({
|
const withFilteredCommands = GROUPS.map((group) => ({
|
||||||
...group,
|
...group,
|
||||||
commands: group.commands.filter((item) => {
|
commands: group.commands.filter((item) => {
|
||||||
const labelNormalized = item.label.toLowerCase().trim();
|
const labelNormalized = item.label.toLowerCase().trim()
|
||||||
const queryNormalized = query.toLowerCase().trim();
|
const queryNormalized = query.toLowerCase().trim()
|
||||||
return labelNormalized.includes(queryNormalized);
|
return labelNormalized.includes(queryNormalized)
|
||||||
})
|
})
|
||||||
}));
|
}))
|
||||||
|
|
||||||
const withoutEmptyGroups = withFilteredCommands.filter((group) => {
|
const withoutEmptyGroups = withFilteredCommands.filter((group) => {
|
||||||
if (group.commands.length > 0) {
|
if (group.commands.length > 0) {
|
||||||
return true;
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false
|
||||||
});
|
})
|
||||||
|
|
||||||
const withEnabledSettings = withoutEmptyGroups.map((group) => ({
|
const withEnabledSettings = withoutEmptyGroups.map((group) => ({
|
||||||
...group,
|
...group,
|
||||||
|
|
@ -101,98 +101,96 @@ export default (menuList: Component<any, any, ''>): Extension =>
|
||||||
...command,
|
...command,
|
||||||
isEnabled: true
|
isEnabled: true
|
||||||
}))
|
}))
|
||||||
}));
|
}))
|
||||||
|
|
||||||
return withEnabledSettings;
|
return withEnabledSettings
|
||||||
},
|
},
|
||||||
render: () => {
|
render: () => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// 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 {
|
return {
|
||||||
onStart: (props: SuggestionProps) => {
|
onStart: (props: SuggestionProps) => {
|
||||||
component = new SvelteRenderer(menuList, {
|
component = new SvelteRenderer(menuList, {
|
||||||
props,
|
props,
|
||||||
editor: props.editor
|
editor: props.editor
|
||||||
});
|
})
|
||||||
|
|
||||||
const { view } = props.editor;
|
const { view } = props.editor
|
||||||
|
|
||||||
const getReferenceClientRect = () => {
|
const getReferenceClientRect = () => {
|
||||||
if (!props.clientRect) {
|
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) {
|
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) {
|
if (rect.top + component.element.offsetHeight + 40 > window.innerHeight) {
|
||||||
const diff =
|
const diff = rect.top + component.element.offsetHeight - window.innerHeight + 40
|
||||||
rect.top + component.element.offsetHeight - window.innerHeight + 40;
|
yPos = rect.y - diff
|
||||||
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 = () => {
|
scrollHandler = () => {
|
||||||
popup?.[0].setProps({
|
popup?.[0].setProps({
|
||||||
getReferenceClientRect
|
getReferenceClientRect
|
||||||
});
|
})
|
||||||
};
|
}
|
||||||
|
|
||||||
view.dom.parentElement?.addEventListener('scroll', scrollHandler);
|
view.dom.parentElement?.addEventListener('scroll', scrollHandler)
|
||||||
|
|
||||||
popup?.[0].setProps({
|
popup?.[0].setProps({
|
||||||
getReferenceClientRect,
|
getReferenceClientRect,
|
||||||
appendTo: () => document.body,
|
appendTo: () => document.body,
|
||||||
content: component.element
|
content: component.element
|
||||||
});
|
})
|
||||||
|
|
||||||
popup?.[0].show();
|
popup?.[0].show()
|
||||||
},
|
},
|
||||||
|
|
||||||
onUpdate(props: SuggestionProps) {
|
onUpdate(props: SuggestionProps) {
|
||||||
component.updateProps(props);
|
component.updateProps(props)
|
||||||
|
|
||||||
const { view } = props.editor;
|
const { view } = props.editor
|
||||||
|
|
||||||
const getReferenceClientRect = () => {
|
const getReferenceClientRect = () => {
|
||||||
if (!props.clientRect) {
|
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) {
|
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) {
|
if (rect.top + component.element.offsetHeight + 40 > window.innerHeight) {
|
||||||
const diff =
|
const diff = rect.top + component.element.offsetHeight - window.innerHeight + 40
|
||||||
rect.top + component.element.offsetHeight - window.innerHeight + 40;
|
yPos = rect.y - diff
|
||||||
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 = () => {
|
const scrollHandler = () => {
|
||||||
popup?.[0].setProps({
|
popup?.[0].setProps({
|
||||||
getReferenceClientRect
|
getReferenceClientRect
|
||||||
});
|
})
|
||||||
};
|
}
|
||||||
|
|
||||||
view.dom.parentElement?.addEventListener('scroll', scrollHandler);
|
view.dom.parentElement?.addEventListener('scroll', scrollHandler)
|
||||||
|
|
||||||
props.editor.storage[extensionName].rect = props.clientRect
|
props.editor.storage[extensionName].rect = props.clientRect
|
||||||
? getReferenceClientRect()
|
? getReferenceClientRect()
|
||||||
|
|
@ -203,40 +201,40 @@ export default (menuList: Component<any, any, ''>): Extension =>
|
||||||
top: 0,
|
top: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
bottom: 0
|
bottom: 0
|
||||||
};
|
}
|
||||||
popup?.[0].setProps({
|
popup?.[0].setProps({
|
||||||
getReferenceClientRect
|
getReferenceClientRect
|
||||||
});
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
onKeyDown(props: SuggestionKeyDownProps) {
|
onKeyDown(props: SuggestionKeyDownProps) {
|
||||||
if (props.event.key === 'Escape') {
|
if (props.event.key === 'Escape') {
|
||||||
popup?.[0].hide();
|
popup?.[0].hide()
|
||||||
return true;
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!popup?.[0].state.isShown) {
|
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 component.ref?.onKeyDown(props);
|
||||||
return false;
|
return false
|
||||||
},
|
},
|
||||||
|
|
||||||
onExit(props) {
|
onExit(props) {
|
||||||
popup?.[0].hide();
|
popup?.[0].hide()
|
||||||
if (scrollHandler) {
|
if (scrollHandler) {
|
||||||
const { view } = props.editor;
|
const { view } = props.editor
|
||||||
view.dom.parentElement?.removeEventListener('scroll', scrollHandler);
|
view.dom.parentElement?.removeEventListener('scroll', scrollHandler)
|
||||||
|
}
|
||||||
|
component.destroy()
|
||||||
}
|
}
|
||||||
component.destroy();
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
];
|
]
|
||||||
},
|
},
|
||||||
|
|
||||||
addStorage() {
|
addStorage() {
|
||||||
|
|
@ -249,6 +247,6 @@ export default (menuList: Component<any, any, ''>): Extension =>
|
||||||
right: 0,
|
right: 0,
|
||||||
bottom: 0
|
bottom: 0
|
||||||
}
|
}
|
||||||
};
|
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
export { Table } from './table.js';
|
export { Table } from './table.js'
|
||||||
export { TableCell } from './table-cell.js';
|
export { TableCell } from './table-cell.js'
|
||||||
export { TableRow } from './table-row.js';
|
export { TableRow } from './table-row.js'
|
||||||
export { TableHeader } from './table-header.js';
|
export { TableHeader } from './table-header.js'
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import { mergeAttributes, Node } from '@tiptap/core';
|
import { mergeAttributes, Node } from '@tiptap/core'
|
||||||
import { Plugin } from '@tiptap/pm/state';
|
import { Plugin } from '@tiptap/pm/state'
|
||||||
import { Decoration, DecorationSet } from '@tiptap/pm/view';
|
import { Decoration, DecorationSet } from '@tiptap/pm/view'
|
||||||
|
|
||||||
import { getCellsInColumn, isRowSelected, selectRow } from './utils.js';
|
import { getCellsInColumn, isRowSelected, selectRow } from './utils.js'
|
||||||
|
|
||||||
export interface TableCellOptions {
|
export interface TableCellOptions {
|
||||||
HTMLAttributes: Record<string, unknown>;
|
HTMLAttributes: Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TableCell = Node.create<TableCellOptions>({
|
export const TableCell = Node.create<TableCellOptions>({
|
||||||
|
|
@ -19,15 +19,15 @@ export const TableCell = Node.create<TableCellOptions>({
|
||||||
addOptions() {
|
addOptions() {
|
||||||
return {
|
return {
|
||||||
HTMLAttributes: {}
|
HTMLAttributes: {}
|
||||||
};
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
parseHTML() {
|
parseHTML() {
|
||||||
return [{ tag: 'td' }];
|
return [{ tag: 'td' }]
|
||||||
},
|
},
|
||||||
|
|
||||||
renderHTML({ HTMLAttributes }) {
|
renderHTML({ HTMLAttributes }) {
|
||||||
return ['td', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0];
|
return ['td', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]
|
||||||
},
|
},
|
||||||
|
|
||||||
addAttributes() {
|
addAttributes() {
|
||||||
|
|
@ -35,90 +35,90 @@ export const TableCell = Node.create<TableCellOptions>({
|
||||||
colspan: {
|
colspan: {
|
||||||
default: 1,
|
default: 1,
|
||||||
parseHTML: (element) => {
|
parseHTML: (element) => {
|
||||||
const colspan = element.getAttribute('colspan');
|
const colspan = element.getAttribute('colspan')
|
||||||
const value = colspan ? parseInt(colspan, 10) : 1;
|
const value = colspan ? parseInt(colspan, 10) : 1
|
||||||
|
|
||||||
return value;
|
return value
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
rowspan: {
|
rowspan: {
|
||||||
default: 1,
|
default: 1,
|
||||||
parseHTML: (element) => {
|
parseHTML: (element) => {
|
||||||
const rowspan = element.getAttribute('rowspan');
|
const rowspan = element.getAttribute('rowspan')
|
||||||
const value = rowspan ? parseInt(rowspan, 10) : 1;
|
const value = rowspan ? parseInt(rowspan, 10) : 1
|
||||||
|
|
||||||
return value;
|
return value
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
colwidth: {
|
colwidth: {
|
||||||
default: null,
|
default: null,
|
||||||
parseHTML: (element) => {
|
parseHTML: (element) => {
|
||||||
const colwidth = element.getAttribute('colwidth');
|
const colwidth = element.getAttribute('colwidth')
|
||||||
const value = colwidth ? [parseInt(colwidth, 10)] : null;
|
const value = colwidth ? [parseInt(colwidth, 10)] : null
|
||||||
|
|
||||||
return value;
|
return value
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
style: {
|
style: {
|
||||||
default: null
|
default: null
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
addProseMirrorPlugins() {
|
addProseMirrorPlugins() {
|
||||||
const { isEditable } = this.editor;
|
const { isEditable } = this.editor
|
||||||
|
|
||||||
return [
|
return [
|
||||||
new Plugin({
|
new Plugin({
|
||||||
props: {
|
props: {
|
||||||
decorations: (state) => {
|
decorations: (state) => {
|
||||||
if (!isEditable) {
|
if (!isEditable) {
|
||||||
return DecorationSet.empty;
|
return DecorationSet.empty
|
||||||
}
|
}
|
||||||
|
|
||||||
const { doc, selection } = state;
|
const { doc, selection } = state
|
||||||
const decorations: Decoration[] = [];
|
const decorations: Decoration[] = []
|
||||||
const cells = getCellsInColumn(0)(selection);
|
const cells = getCellsInColumn(0)(selection)
|
||||||
|
|
||||||
if (cells) {
|
if (cells) {
|
||||||
cells.forEach(({ pos }: { pos: number }, index: number) => {
|
cells.forEach(({ pos }: { pos: number }, index: number) => {
|
||||||
decorations.push(
|
decorations.push(
|
||||||
Decoration.widget(pos + 1, () => {
|
Decoration.widget(pos + 1, () => {
|
||||||
const rowSelected = isRowSelected(index)(selection);
|
const rowSelected = isRowSelected(index)(selection)
|
||||||
let className = 'grip-row';
|
let className = 'grip-row'
|
||||||
|
|
||||||
if (rowSelected) {
|
if (rowSelected) {
|
||||||
className += ' selected';
|
className += ' selected'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (index === 0) {
|
if (index === 0) {
|
||||||
className += ' first';
|
className += ' first'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (index === cells.length - 1) {
|
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) => {
|
grip.addEventListener('mousedown', (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault()
|
||||||
event.stopImmediatePropagation();
|
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 TiptapTableHeader from '@tiptap/extension-table-header'
|
||||||
import { Plugin } from '@tiptap/pm/state';
|
import { Plugin } from '@tiptap/pm/state'
|
||||||
import { Decoration, DecorationSet } from '@tiptap/pm/view';
|
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({
|
export const TableHeader = TiptapTableHeader.extend({
|
||||||
addAttributes() {
|
addAttributes() {
|
||||||
|
|
@ -16,74 +16,74 @@ export const TableHeader = TiptapTableHeader.extend({
|
||||||
colwidth: {
|
colwidth: {
|
||||||
default: null,
|
default: null,
|
||||||
parseHTML: (element) => {
|
parseHTML: (element) => {
|
||||||
const colwidth = element.getAttribute('colwidth');
|
const colwidth = element.getAttribute('colwidth')
|
||||||
const value = colwidth ? colwidth.split(',').map((item) => parseInt(item, 10)) : null;
|
const value = colwidth ? colwidth.split(',').map((item) => parseInt(item, 10)) : null
|
||||||
|
|
||||||
return value;
|
return value
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
style: {
|
style: {
|
||||||
default: null
|
default: null
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
addProseMirrorPlugins() {
|
addProseMirrorPlugins() {
|
||||||
const { isEditable } = this.editor;
|
const { isEditable } = this.editor
|
||||||
|
|
||||||
return [
|
return [
|
||||||
new Plugin({
|
new Plugin({
|
||||||
props: {
|
props: {
|
||||||
decorations: (state) => {
|
decorations: (state) => {
|
||||||
if (!isEditable) {
|
if (!isEditable) {
|
||||||
return DecorationSet.empty;
|
return DecorationSet.empty
|
||||||
}
|
}
|
||||||
|
|
||||||
const { doc, selection } = state;
|
const { doc, selection } = state
|
||||||
const decorations: Decoration[] = [];
|
const decorations: Decoration[] = []
|
||||||
const cells = getCellsInRow(0)(selection);
|
const cells = getCellsInRow(0)(selection)
|
||||||
|
|
||||||
if (cells) {
|
if (cells) {
|
||||||
cells.forEach(({ pos }: { pos: number }, index: number) => {
|
cells.forEach(({ pos }: { pos: number }, index: number) => {
|
||||||
decorations.push(
|
decorations.push(
|
||||||
Decoration.widget(pos + 1, () => {
|
Decoration.widget(pos + 1, () => {
|
||||||
const colSelected = isColumnSelected(index)(selection);
|
const colSelected = isColumnSelected(index)(selection)
|
||||||
let className = 'grip-column';
|
let className = 'grip-column'
|
||||||
|
|
||||||
if (colSelected) {
|
if (colSelected) {
|
||||||
className += ' selected';
|
className += ' selected'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (index === 0) {
|
if (index === 0) {
|
||||||
className += ' first';
|
className += ' first'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (index === cells.length - 1) {
|
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) => {
|
grip.addEventListener('mousedown', (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault()
|
||||||
event.stopImmediatePropagation();
|
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({
|
export const TableRow = TiptapTableRow.extend({
|
||||||
allowGapCursor: false,
|
allowGapCursor: false,
|
||||||
content: 'tableCell*'
|
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({
|
export const Table = TiptapTable.configure({
|
||||||
resizable: true,
|
resizable: true,
|
||||||
lastColumnResizable: true,
|
lastColumnResizable: true,
|
||||||
allowTableNodeSelection: true
|
allowTableNodeSelection: true
|
||||||
});
|
})
|
||||||
|
|
||||||
export default Table;
|
export default Table
|
||||||
|
|
|
||||||
|
|
@ -1,85 +1,85 @@
|
||||||
import { Editor, findParentNode } from '@tiptap/core';
|
import { Editor, findParentNode } from '@tiptap/core'
|
||||||
import { EditorState, Selection, Transaction } from '@tiptap/pm/state';
|
import { EditorState, Selection, Transaction } from '@tiptap/pm/state'
|
||||||
import { CellSelection, type Rect, TableMap } from '@tiptap/pm/tables';
|
import { CellSelection, type Rect, TableMap } from '@tiptap/pm/tables'
|
||||||
import { Node, ResolvedPos } from '@tiptap/pm/model';
|
import { Node, ResolvedPos } from '@tiptap/pm/model'
|
||||||
import type { EditorView } from '@tiptap/pm/view';
|
import type { EditorView } from '@tiptap/pm/view'
|
||||||
import Table from './table.js';
|
import Table from './table.js'
|
||||||
|
|
||||||
export const isRectSelected = (rect: Rect) => (selection: CellSelection) => {
|
export const isRectSelected = (rect: Rect) => (selection: CellSelection) => {
|
||||||
const map = TableMap.get(selection.$anchorCell.node(-1));
|
const map = TableMap.get(selection.$anchorCell.node(-1))
|
||||||
const start = selection.$anchorCell.start(-1);
|
const start = selection.$anchorCell.start(-1)
|
||||||
const cells = map.cellsInRect(rect);
|
const cells = map.cellsInRect(rect)
|
||||||
const selectedCells = map.cellsInRect(
|
const selectedCells = map.cellsInRect(
|
||||||
map.rectBetween(selection.$anchorCell.pos - start, selection.$headCell.pos - start)
|
map.rectBetween(selection.$anchorCell.pos - start, selection.$headCell.pos - start)
|
||||||
);
|
)
|
||||||
|
|
||||||
for (let i = 0, count = cells.length; i < count; i += 1) {
|
for (let i = 0, count = cells.length; i < count; i += 1) {
|
||||||
if (selectedCells.indexOf(cells[i]) === -1) {
|
if (selectedCells.indexOf(cells[i]) === -1) {
|
||||||
return false;
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true
|
||||||
};
|
}
|
||||||
|
|
||||||
export const findTable = (selection: Selection) =>
|
export const findTable = (selection: Selection) =>
|
||||||
findParentNode((node) => node.type.spec.tableRole && node.type.spec.tableRole === 'table')(
|
findParentNode((node) => node.type.spec.tableRole && node.type.spec.tableRole === 'table')(
|
||||||
selection
|
selection
|
||||||
);
|
)
|
||||||
|
|
||||||
export const isCellSelection = (selection: Selection): selection is CellSelection =>
|
export const isCellSelection = (selection: Selection): selection is CellSelection =>
|
||||||
selection instanceof CellSelection;
|
selection instanceof CellSelection
|
||||||
|
|
||||||
export const isColumnSelected = (columnIndex: number) => (selection: Selection) => {
|
export const isColumnSelected = (columnIndex: number) => (selection: Selection) => {
|
||||||
if (isCellSelection(selection)) {
|
if (isCellSelection(selection)) {
|
||||||
const map = TableMap.get(selection.$anchorCell.node(-1));
|
const map = TableMap.get(selection.$anchorCell.node(-1))
|
||||||
|
|
||||||
return isRectSelected({
|
return isRectSelected({
|
||||||
left: columnIndex,
|
left: columnIndex,
|
||||||
right: columnIndex + 1,
|
right: columnIndex + 1,
|
||||||
top: 0,
|
top: 0,
|
||||||
bottom: map.height
|
bottom: map.height
|
||||||
})(selection);
|
})(selection)
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false
|
||||||
};
|
}
|
||||||
|
|
||||||
export const isRowSelected = (rowIndex: number) => (selection: Selection) => {
|
export const isRowSelected = (rowIndex: number) => (selection: Selection) => {
|
||||||
if (isCellSelection(selection)) {
|
if (isCellSelection(selection)) {
|
||||||
const map = TableMap.get(selection.$anchorCell.node(-1));
|
const map = TableMap.get(selection.$anchorCell.node(-1))
|
||||||
|
|
||||||
return isRectSelected({
|
return isRectSelected({
|
||||||
left: 0,
|
left: 0,
|
||||||
right: map.width,
|
right: map.width,
|
||||||
top: rowIndex,
|
top: rowIndex,
|
||||||
bottom: rowIndex + 1
|
bottom: rowIndex + 1
|
||||||
})(selection);
|
})(selection)
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false
|
||||||
};
|
}
|
||||||
|
|
||||||
export const isTableSelected = (selection: Selection) => {
|
export const isTableSelected = (selection: Selection) => {
|
||||||
if (isCellSelection(selection)) {
|
if (isCellSelection(selection)) {
|
||||||
const map = TableMap.get(selection.$anchorCell.node(-1));
|
const map = TableMap.get(selection.$anchorCell.node(-1))
|
||||||
|
|
||||||
return isRectSelected({
|
return isRectSelected({
|
||||||
left: 0,
|
left: 0,
|
||||||
right: map.width,
|
right: map.width,
|
||||||
top: 0,
|
top: 0,
|
||||||
bottom: map.height
|
bottom: map.height
|
||||||
})(selection);
|
})(selection)
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false
|
||||||
};
|
}
|
||||||
|
|
||||||
export const getCellsInColumn = (columnIndex: number | number[]) => (selection: Selection) => {
|
export const getCellsInColumn = (columnIndex: number | number[]) => (selection: Selection) => {
|
||||||
const table = findTable(selection);
|
const table = findTable(selection)
|
||||||
if (table) {
|
if (table) {
|
||||||
const map = TableMap.get(table.node);
|
const map = TableMap.get(table.node)
|
||||||
const indexes = Array.isArray(columnIndex) ? columnIndex : Array.from([columnIndex]);
|
const indexes = Array.isArray(columnIndex) ? columnIndex : Array.from([columnIndex])
|
||||||
|
|
||||||
return indexes.reduce(
|
return indexes.reduce(
|
||||||
(acc, index) => {
|
(acc, index) => {
|
||||||
|
|
@ -89,32 +89,32 @@ export const getCellsInColumn = (columnIndex: number | number[]) => (selection:
|
||||||
right: index + 1,
|
right: index + 1,
|
||||||
top: 0,
|
top: 0,
|
||||||
bottom: map.height
|
bottom: map.height
|
||||||
});
|
})
|
||||||
|
|
||||||
return acc.concat(
|
return acc.concat(
|
||||||
cells.map((nodePos) => {
|
cells.map((nodePos) => {
|
||||||
const node = table.node.nodeAt(nodePos);
|
const node = table.node.nodeAt(nodePos)
|
||||||
const pos = nodePos + table.start;
|
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 }[]
|
[] as { pos: number; start: number; node: Node | null | undefined }[]
|
||||||
);
|
)
|
||||||
|
}
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getCellsInRow = (rowIndex: number | number[]) => (selection: Selection) => {
|
export const getCellsInRow = (rowIndex: number | number[]) => (selection: Selection) => {
|
||||||
const table = findTable(selection);
|
const table = findTable(selection)
|
||||||
|
|
||||||
if (table) {
|
if (table) {
|
||||||
const map = TableMap.get(table.node);
|
const map = TableMap.get(table.node)
|
||||||
const indexes = Array.isArray(rowIndex) ? rowIndex : Array.from([rowIndex]);
|
const indexes = Array.isArray(rowIndex) ? rowIndex : Array.from([rowIndex])
|
||||||
|
|
||||||
return indexes.reduce(
|
return indexes.reduce(
|
||||||
(acc, index) => {
|
(acc, index) => {
|
||||||
|
|
@ -124,55 +124,55 @@ export const getCellsInRow = (rowIndex: number | number[]) => (selection: Select
|
||||||
right: map.width,
|
right: map.width,
|
||||||
top: index,
|
top: index,
|
||||||
bottom: index + 1
|
bottom: index + 1
|
||||||
});
|
})
|
||||||
|
|
||||||
return acc.concat(
|
return acc.concat(
|
||||||
cells.map((nodePos) => {
|
cells.map((nodePos) => {
|
||||||
const node = table.node.nodeAt(nodePos);
|
const node = table.node.nodeAt(nodePos)
|
||||||
const pos = nodePos + table.start;
|
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 }[]
|
[] as { pos: number; start: number; node: Node | null | undefined }[]
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null
|
||||||
};
|
}
|
||||||
|
|
||||||
export const getCellsInTable = (selection: Selection) => {
|
export const getCellsInTable = (selection: Selection) => {
|
||||||
const table = findTable(selection);
|
const table = findTable(selection)
|
||||||
|
|
||||||
if (table) {
|
if (table) {
|
||||||
const map = TableMap.get(table.node);
|
const map = TableMap.get(table.node)
|
||||||
const cells = map.cellsInRect({
|
const cells = map.cellsInRect({
|
||||||
left: 0,
|
left: 0,
|
||||||
right: map.width,
|
right: map.width,
|
||||||
top: 0,
|
top: 0,
|
||||||
bottom: map.height
|
bottom: map.height
|
||||||
});
|
})
|
||||||
|
|
||||||
return cells.map((nodePos) => {
|
return cells.map((nodePos) => {
|
||||||
const node = table.node.nodeAt(nodePos);
|
const node = table.node.nodeAt(nodePos)
|
||||||
const pos = nodePos + table.start;
|
const pos = nodePos + table.start
|
||||||
|
|
||||||
return { pos, start: pos + 1, node };
|
return { pos, start: pos + 1, node }
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null
|
||||||
};
|
}
|
||||||
|
|
||||||
export const findParentNodeClosestToPos = (
|
export const findParentNodeClosestToPos = (
|
||||||
$pos: ResolvedPos,
|
$pos: ResolvedPos,
|
||||||
predicate: (node: Node) => boolean
|
predicate: (node: Node) => boolean
|
||||||
) => {
|
) => {
|
||||||
for (let i = $pos.depth; i > 0; i -= 1) {
|
for (let i = $pos.depth; i > 0; i -= 1) {
|
||||||
const node = $pos.node(i);
|
const node = $pos.node(i)
|
||||||
|
|
||||||
if (predicate(node)) {
|
if (predicate(node)) {
|
||||||
return {
|
return {
|
||||||
|
|
@ -180,40 +180,40 @@ export const findParentNodeClosestToPos = (
|
||||||
start: $pos.start(i),
|
start: $pos.start(i),
|
||||||
depth: i,
|
depth: i,
|
||||||
node
|
node
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null
|
||||||
};
|
}
|
||||||
|
|
||||||
export const findCellClosestToPos = ($pos: ResolvedPos) => {
|
export const findCellClosestToPos = ($pos: ResolvedPos) => {
|
||||||
const predicate = (node: Node) =>
|
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 select = (type: 'row' | 'column') => (index: number) => (tr: Transaction) => {
|
||||||
const table = findTable(tr.selection);
|
const table = findTable(tr.selection)
|
||||||
const isRowSelection = type === 'row';
|
const isRowSelection = type === 'row'
|
||||||
|
|
||||||
if (table) {
|
if (table) {
|
||||||
const map = TableMap.get(table.node);
|
const map = TableMap.get(table.node)
|
||||||
|
|
||||||
// Check if the index is valid
|
// Check if the index is valid
|
||||||
if (index >= 0 && index < (isRowSelection ? map.height : map.width)) {
|
if (index >= 0 && index < (isRowSelection ? map.height : map.width)) {
|
||||||
const left = isRowSelection ? 0 : index;
|
const left = isRowSelection ? 0 : index
|
||||||
const top = isRowSelection ? index : 0;
|
const top = isRowSelection ? index : 0
|
||||||
const right = isRowSelection ? map.width : index + 1;
|
const right = isRowSelection ? map.width : index + 1
|
||||||
const bottom = isRowSelection ? index + 1 : map.height;
|
const bottom = isRowSelection ? index + 1 : map.height
|
||||||
|
|
||||||
const cellsInFirstRow = map.cellsInRect({
|
const cellsInFirstRow = map.cellsInRect({
|
||||||
left,
|
left,
|
||||||
top,
|
top,
|
||||||
right: isRowSelection ? right : left + 1,
|
right: isRowSelection ? right : left + 1,
|
||||||
bottom: isRowSelection ? top + 1 : bottom
|
bottom: isRowSelection ? top + 1 : bottom
|
||||||
});
|
})
|
||||||
|
|
||||||
const cellsInLastRow =
|
const cellsInLastRow =
|
||||||
bottom - top === 1
|
bottom - top === 1
|
||||||
|
|
@ -223,41 +223,41 @@ const select = (type: 'row' | 'column') => (index: number) => (tr: Transaction)
|
||||||
top: isRowSelection ? bottom - 1 : top,
|
top: isRowSelection ? bottom - 1 : top,
|
||||||
right,
|
right,
|
||||||
bottom
|
bottom
|
||||||
});
|
})
|
||||||
|
|
||||||
const head = table.start + cellsInFirstRow[0];
|
const head = table.start + cellsInFirstRow[0]
|
||||||
const anchor = table.start + cellsInLastRow[cellsInLastRow.length - 1];
|
const anchor = table.start + cellsInLastRow[cellsInLastRow.length - 1]
|
||||||
const $head = tr.doc.resolve(head);
|
const $head = tr.doc.resolve(head)
|
||||||
const $anchor = tr.doc.resolve(anchor);
|
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) => {
|
export const selectTable = (tr: Transaction) => {
|
||||||
const table = findTable(tr.selection);
|
const table = findTable(tr.selection)
|
||||||
|
|
||||||
if (table) {
|
if (table) {
|
||||||
const { map } = TableMap.get(table.node);
|
const { map } = TableMap.get(table.node)
|
||||||
|
|
||||||
if (map && map.length) {
|
if (map && map.length) {
|
||||||
const head = table.start + map[0];
|
const head = table.start + map[0]
|
||||||
const anchor = table.start + map[map.length - 1];
|
const anchor = table.start + map[map.length - 1]
|
||||||
const $head = tr.doc.resolve(head);
|
const $head = tr.doc.resolve(head)
|
||||||
const $anchor = tr.doc.resolve(anchor);
|
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 = ({
|
export const isColumnGripSelected = ({
|
||||||
editor,
|
editor,
|
||||||
|
|
@ -265,30 +265,30 @@ export const isColumnGripSelected = ({
|
||||||
state,
|
state,
|
||||||
from
|
from
|
||||||
}: {
|
}: {
|
||||||
editor: Editor;
|
editor: Editor
|
||||||
view: EditorView;
|
view: EditorView
|
||||||
state: EditorState;
|
state: EditorState
|
||||||
from: number;
|
from: number
|
||||||
}) => {
|
}) => {
|
||||||
const domAtPos = view.domAtPos(from).node as HTMLElement;
|
const domAtPos = view.domAtPos(from).node as HTMLElement
|
||||||
const nodeDOM = view.nodeDOM(from) as HTMLElement;
|
const nodeDOM = view.nodeDOM(from) as HTMLElement
|
||||||
const node = nodeDOM || domAtPos;
|
const node = nodeDOM || domAtPos
|
||||||
|
|
||||||
if (!editor.isActive(Table.name) || !node || isTableSelected(state.selection)) {
|
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)) {
|
while (container && !['TD', 'TH'].includes(container.tagName)) {
|
||||||
container = container.parentElement!;
|
container = container.parentElement!
|
||||||
}
|
}
|
||||||
|
|
||||||
const gripColumn =
|
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 = ({
|
export const isRowGripSelected = ({
|
||||||
editor,
|
editor,
|
||||||
|
|
@ -296,27 +296,27 @@ export const isRowGripSelected = ({
|
||||||
state,
|
state,
|
||||||
from
|
from
|
||||||
}: {
|
}: {
|
||||||
editor: Editor;
|
editor: Editor
|
||||||
view: EditorView;
|
view: EditorView
|
||||||
state: EditorState;
|
state: EditorState
|
||||||
from: number;
|
from: number
|
||||||
}) => {
|
}) => {
|
||||||
const domAtPos = view.domAtPos(from).node as HTMLElement;
|
const domAtPos = view.domAtPos(from).node as HTMLElement
|
||||||
const nodeDOM = view.nodeDOM(from) as HTMLElement;
|
const nodeDOM = view.nodeDOM(from) as HTMLElement
|
||||||
const node = nodeDOM || domAtPos;
|
const node = nodeDOM || domAtPos
|
||||||
|
|
||||||
if (!editor.isActive(Table.name) || !node || isTableSelected(state.selection)) {
|
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)) {
|
while (container && !['TD', 'TH'].includes(container.tagName)) {
|
||||||
container = container.parentElement!;
|
container = container.parentElement!
|
||||||
}
|
}
|
||||||
|
|
||||||
const gripRow =
|
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 { SvelteNodeViewRenderer } from 'svelte-tiptap'
|
||||||
import { Video } from './VideoExtension.js';
|
import { Video } from './VideoExtension.js'
|
||||||
import type { NodeViewProps } from '@tiptap/core';
|
import type { NodeViewProps } from '@tiptap/core'
|
||||||
import type { Component } from 'svelte';
|
import type { Component } from 'svelte'
|
||||||
|
|
||||||
export const VideoExtended = (content: Component<NodeViewProps>) =>
|
export const VideoExtended = (content: Component<NodeViewProps>) =>
|
||||||
Video.extend({
|
Video.extend({
|
||||||
|
|
@ -25,10 +25,10 @@ export const VideoExtended = (content: Component<NodeViewProps>) =>
|
||||||
align: {
|
align: {
|
||||||
default: 'left'
|
default: 'left'
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
addNodeView: () => {
|
addNodeView: () => {
|
||||||
return SvelteNodeViewRenderer(content);
|
return SvelteNodeViewRenderer(content)
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { Node, nodeInputRule } from '@tiptap/core';
|
import { Node, nodeInputRule } from '@tiptap/core'
|
||||||
import { Plugin, PluginKey } from '@tiptap/pm/state';
|
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
||||||
|
|
||||||
export interface VideoOptions {
|
export interface VideoOptions {
|
||||||
HTMLAttributes: Record<string, unknown>;
|
HTMLAttributes: Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '@tiptap/core' {
|
declare module '@tiptap/core' {
|
||||||
|
|
@ -11,20 +11,20 @@ declare module '@tiptap/core' {
|
||||||
/**
|
/**
|
||||||
* Set a video node
|
* Set a video node
|
||||||
*/
|
*/
|
||||||
setVideo: (src: string) => ReturnType;
|
setVideo: (src: string) => ReturnType
|
||||||
/**
|
/**
|
||||||
* Toggle a video
|
* Toggle a video
|
||||||
*/
|
*/
|
||||||
toggleVideo: (src: string) => ReturnType;
|
toggleVideo: (src: string) => ReturnType
|
||||||
/**
|
/**
|
||||||
* Remove a video
|
* 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>({
|
export const Video = Node.create<VideoOptions>({
|
||||||
name: 'video',
|
name: 'video',
|
||||||
|
|
@ -35,7 +35,7 @@ export const Video = Node.create<VideoOptions>({
|
||||||
addOptions() {
|
addOptions() {
|
||||||
return {
|
return {
|
||||||
HTMLAttributes: {}
|
HTMLAttributes: {}
|
||||||
};
|
}
|
||||||
},
|
},
|
||||||
addAttributes() {
|
addAttributes() {
|
||||||
return {
|
return {
|
||||||
|
|
@ -44,7 +44,7 @@ export const Video = Node.create<VideoOptions>({
|
||||||
parseHTML: (el) => (el as HTMLSpanElement).getAttribute('src'),
|
parseHTML: (el) => (el as HTMLSpanElement).getAttribute('src'),
|
||||||
renderHTML: (attrs) => ({ src: attrs.src })
|
renderHTML: (attrs) => ({ src: attrs.src })
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
},
|
},
|
||||||
parseHTML() {
|
parseHTML() {
|
||||||
return [
|
return [
|
||||||
|
|
@ -52,7 +52,7 @@ export const Video = Node.create<VideoOptions>({
|
||||||
tag: 'video',
|
tag: 'video',
|
||||||
getAttrs: (el) => ({ src: (el as HTMLVideoElement).getAttribute('src') })
|
getAttrs: (el) => ({ src: (el as HTMLVideoElement).getAttribute('src') })
|
||||||
}
|
}
|
||||||
];
|
]
|
||||||
},
|
},
|
||||||
|
|
||||||
renderHTML({ HTMLAttributes }) {
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
|
@ -60,7 +60,7 @@ export const Video = Node.create<VideoOptions>({
|
||||||
'video',
|
'video',
|
||||||
{ controls: 'true', style: 'width: fit-content;', ...HTMLAttributes },
|
{ controls: 'true', style: 'width: fit-content;', ...HTMLAttributes },
|
||||||
['source', HTMLAttributes]
|
['source', HTMLAttributes]
|
||||||
];
|
]
|
||||||
},
|
},
|
||||||
addCommands() {
|
addCommands() {
|
||||||
return {
|
return {
|
||||||
|
|
@ -79,7 +79,7 @@ export const Video = Node.create<VideoOptions>({
|
||||||
() =>
|
() =>
|
||||||
({ commands }) =>
|
({ commands }) =>
|
||||||
commands.deleteNode(this.name)
|
commands.deleteNode(this.name)
|
||||||
};
|
}
|
||||||
},
|
},
|
||||||
addInputRules() {
|
addInputRules() {
|
||||||
return [
|
return [
|
||||||
|
|
@ -87,12 +87,12 @@ export const Video = Node.create<VideoOptions>({
|
||||||
find: VIDEO_INPUT_REGEX,
|
find: VIDEO_INPUT_REGEX,
|
||||||
type: this.type,
|
type: this.type,
|
||||||
getAttributes: (match) => {
|
getAttributes: (match) => {
|
||||||
const [, , src] = match;
|
const [, , src] = match
|
||||||
|
|
||||||
return { src };
|
return { src }
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
];
|
]
|
||||||
},
|
},
|
||||||
addProseMirrorPlugins() {
|
addProseMirrorPlugins() {
|
||||||
return [
|
return [
|
||||||
|
|
@ -105,43 +105,43 @@ export const Video = Node.create<VideoOptions>({
|
||||||
const {
|
const {
|
||||||
state: { schema, tr },
|
state: { schema, tr },
|
||||||
dispatch
|
dispatch
|
||||||
} = view;
|
} = view
|
||||||
const hasFiles =
|
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) =>
|
const videos = Array.from(event.dataTransfer.files).filter((file) =>
|
||||||
/video/i.test(file.type)
|
/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) => {
|
videos.forEach((video) => {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader()
|
||||||
|
|
||||||
reader.onload = (readerEvent) => {
|
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') {
|
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 { Editor, Node, mergeAttributes, type CommandProps, type NodeViewProps } from '@tiptap/core'
|
||||||
import type { Component } from 'svelte';
|
import type { Component } from 'svelte'
|
||||||
import { SvelteNodeViewRenderer } from 'svelte-tiptap';
|
import { SvelteNodeViewRenderer } from 'svelte-tiptap'
|
||||||
|
|
||||||
export interface VideoPlaceholderOptions {
|
export interface VideoPlaceholderOptions {
|
||||||
HTMLAttributes: Record<string, object>;
|
HTMLAttributes: Record<string, object>
|
||||||
onDrop: (files: File[], editor: Editor) => void;
|
onDrop: (files: File[], editor: Editor) => void
|
||||||
onDropRejected?: (files: File[], editor: Editor) => void;
|
onDropRejected?: (files: File[], editor: Editor) => void
|
||||||
onEmbed: (url: string, editor: Editor) => void;
|
onEmbed: (url: string, editor: Editor) => void
|
||||||
allowedMimeTypes?: Record<string, string[]>;
|
allowedMimeTypes?: Record<string, string[]>
|
||||||
maxFiles?: number;
|
maxFiles?: number
|
||||||
maxSize?: number;
|
maxSize?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '@tiptap/core' {
|
declare module '@tiptap/core' {
|
||||||
|
|
@ -18,8 +18,8 @@ declare module '@tiptap/core' {
|
||||||
/**
|
/**
|
||||||
* Inserts a video placeholder
|
* Inserts a video placeholder
|
||||||
*/
|
*/
|
||||||
insertVideoPlaceholder: () => ReturnType;
|
insertVideoPlaceholder: () => ReturnType
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -32,14 +32,14 @@ export const VideoPlaceholder = (content: Component<NodeViewProps>) =>
|
||||||
onDrop: () => {},
|
onDrop: () => {},
|
||||||
onDropRejected: () => {},
|
onDropRejected: () => {},
|
||||||
onEmbed: () => {}
|
onEmbed: () => {}
|
||||||
};
|
}
|
||||||
},
|
},
|
||||||
parseHTML() {
|
parseHTML() {
|
||||||
return [{ tag: `div[data-type="${this.name}"]` }];
|
return [{ tag: `div[data-type="${this.name}"]` }]
|
||||||
},
|
},
|
||||||
|
|
||||||
renderHTML({ HTMLAttributes }) {
|
renderHTML({ HTMLAttributes }) {
|
||||||
return ['div', mergeAttributes(HTMLAttributes)];
|
return ['div', mergeAttributes(HTMLAttributes)]
|
||||||
},
|
},
|
||||||
group: 'block',
|
group: 'block',
|
||||||
draggable: true,
|
draggable: true,
|
||||||
|
|
@ -48,15 +48,15 @@ export const VideoPlaceholder = (content: Component<NodeViewProps>) =>
|
||||||
isolating: true,
|
isolating: true,
|
||||||
|
|
||||||
addNodeView() {
|
addNodeView() {
|
||||||
return SvelteNodeViewRenderer(content);
|
return SvelteNodeViewRenderer(content)
|
||||||
},
|
},
|
||||||
addCommands() {
|
addCommands() {
|
||||||
return {
|
return {
|
||||||
insertVideoPlaceholder: () => (props: CommandProps) => {
|
insertVideoPlaceholder: () => (props: CommandProps) => {
|
||||||
return props.commands.insertContent({
|
return props.commands.insertContent({
|
||||||
type: 'video-placeholder'
|
type: 'video-placeholder'
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
};
|
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,112 +1,112 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy, onMount } from 'svelte';
|
import { onDestroy, onMount } from 'svelte'
|
||||||
import { NodeViewWrapper } from 'svelte-tiptap';
|
import { NodeViewWrapper } from 'svelte-tiptap'
|
||||||
import type { NodeViewProps } from '@tiptap/core';
|
import type { NodeViewProps } from '@tiptap/core'
|
||||||
|
|
||||||
import AlignLeft from 'lucide-svelte/icons/align-left';
|
import AlignLeft from 'lucide-svelte/icons/align-left'
|
||||||
import AlignCenter from 'lucide-svelte/icons/align-center';
|
import AlignCenter from 'lucide-svelte/icons/align-center'
|
||||||
import AlignRight from 'lucide-svelte/icons/align-right';
|
import AlignRight from 'lucide-svelte/icons/align-right'
|
||||||
import CopyIcon from 'lucide-svelte/icons/copy';
|
import CopyIcon from 'lucide-svelte/icons/copy'
|
||||||
import Fullscreen from 'lucide-svelte/icons/fullscreen';
|
import Fullscreen from 'lucide-svelte/icons/fullscreen'
|
||||||
import Trash from 'lucide-svelte/icons/trash';
|
import Trash from 'lucide-svelte/icons/trash'
|
||||||
import Captions from 'lucide-svelte/icons/captions';
|
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 audRef: HTMLAudioElement
|
||||||
let nodeRef: HTMLDivElement;
|
let nodeRef: HTMLDivElement
|
||||||
|
|
||||||
let caption: string | null = $state(node.attrs.title);
|
let caption: string | null = $state(node.attrs.title)
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (caption?.trim() === '') caption = null;
|
if (caption?.trim() === '') caption = null
|
||||||
updateAttributes({ title: caption });
|
updateAttributes({ title: caption })
|
||||||
});
|
})
|
||||||
|
|
||||||
let resizing = $state(false);
|
let resizing = $state(false)
|
||||||
let resizingInitialWidth = $state(0);
|
let resizingInitialWidth = $state(0)
|
||||||
let resizingInitialMouseX = $state(0);
|
let resizingInitialMouseX = $state(0)
|
||||||
let resizingPosition = $state<'left' | 'right'>('left');
|
let resizingPosition = $state<'left' | 'right'>('left')
|
||||||
|
|
||||||
function handleResizingPosition(e: MouseEvent, position: 'left' | 'right') {
|
function handleResizingPosition(e: MouseEvent, position: 'left' | 'right') {
|
||||||
startResize(e);
|
startResize(e)
|
||||||
resizingPosition = position;
|
resizingPosition = position
|
||||||
}
|
}
|
||||||
|
|
||||||
function startResize(e: MouseEvent) {
|
function startResize(e: MouseEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault()
|
||||||
resizing = true;
|
resizing = true
|
||||||
resizingInitialMouseX = e.clientX;
|
resizingInitialMouseX = e.clientX
|
||||||
if (audRef) resizingInitialWidth = audRef.offsetWidth;
|
if (audRef) resizingInitialWidth = audRef.offsetWidth
|
||||||
}
|
}
|
||||||
|
|
||||||
function resize(e: MouseEvent) {
|
function resize(e: MouseEvent) {
|
||||||
if (!resizing) return;
|
if (!resizing) return
|
||||||
let dx = e.clientX - resizingInitialMouseX;
|
let dx = e.clientX - resizingInitialMouseX
|
||||||
if (resizingPosition === 'left') {
|
if (resizingPosition === 'left') {
|
||||||
dx = resizingInitialMouseX - e.clientX;
|
dx = resizingInitialMouseX - e.clientX
|
||||||
}
|
}
|
||||||
const newWidth = Math.max(resizingInitialWidth + dx, minWidth);
|
const newWidth = Math.max(resizingInitialWidth + dx, minWidth)
|
||||||
const parentWidth = nodeRef?.parentElement?.offsetWidth || 0;
|
const parentWidth = nodeRef?.parentElement?.offsetWidth || 0
|
||||||
if (newWidth < parentWidth) {
|
if (newWidth < parentWidth) {
|
||||||
updateAttributes({ width: newWidth });
|
updateAttributes({ width: newWidth })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function endResize() {
|
function endResize() {
|
||||||
resizing = false;
|
resizing = false
|
||||||
resizingInitialMouseX = 0;
|
resizingInitialMouseX = 0
|
||||||
resizingInitialWidth = 0;
|
resizingInitialWidth = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleTouchStart(e: TouchEvent, position: 'left' | 'right') {
|
function handleTouchStart(e: TouchEvent, position: 'left' | 'right') {
|
||||||
e.preventDefault();
|
e.preventDefault()
|
||||||
resizing = true;
|
resizing = true
|
||||||
resizingPosition = position;
|
resizingPosition = position
|
||||||
resizingInitialMouseX = e.touches[0].clientX;
|
resizingInitialMouseX = e.touches[0].clientX
|
||||||
if (audRef) resizingInitialWidth = audRef.offsetWidth;
|
if (audRef) resizingInitialWidth = audRef.offsetWidth
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleTouchMove(e: TouchEvent) {
|
function handleTouchMove(e: TouchEvent) {
|
||||||
if (!resizing) return;
|
if (!resizing) return
|
||||||
let dx = e.touches[0].clientX - resizingInitialMouseX;
|
let dx = e.touches[0].clientX - resizingInitialMouseX
|
||||||
if (resizingPosition === 'left') {
|
if (resizingPosition === 'left') {
|
||||||
dx = resizingInitialMouseX - e.touches[0].clientX;
|
dx = resizingInitialMouseX - e.touches[0].clientX
|
||||||
}
|
}
|
||||||
const newWidth = Math.max(resizingInitialWidth + dx, minWidth);
|
const newWidth = Math.max(resizingInitialWidth + dx, minWidth)
|
||||||
const parentWidth = nodeRef?.parentElement?.offsetWidth || 0;
|
const parentWidth = nodeRef?.parentElement?.offsetWidth || 0
|
||||||
if (newWidth < parentWidth) {
|
if (newWidth < parentWidth) {
|
||||||
updateAttributes({ width: newWidth });
|
updateAttributes({ width: newWidth })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleTouchEnd() {
|
function handleTouchEnd() {
|
||||||
resizing = false;
|
resizing = false
|
||||||
resizingInitialMouseX = 0;
|
resizingInitialMouseX = 0
|
||||||
resizingInitialWidth = 0;
|
resizingInitialWidth = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
// Attach id to nodeRef
|
// Attach id to nodeRef
|
||||||
nodeRef = document.getElementById('resizable-container-audio') as HTMLDivElement;
|
nodeRef = document.getElementById('resizable-container-audio') as HTMLDivElement
|
||||||
|
|
||||||
// Mouse events
|
// Mouse events
|
||||||
window.addEventListener('mousemove', resize);
|
window.addEventListener('mousemove', resize)
|
||||||
window.addEventListener('mouseup', endResize);
|
window.addEventListener('mouseup', endResize)
|
||||||
// Touch events
|
// Touch events
|
||||||
window.addEventListener('touchmove', handleTouchMove);
|
window.addEventListener('touchmove', handleTouchMove)
|
||||||
window.addEventListener('touchend', handleTouchEnd);
|
window.addEventListener('touchend', handleTouchEnd)
|
||||||
});
|
})
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
window.removeEventListener('mousemove', resize);
|
window.removeEventListener('mousemove', resize)
|
||||||
window.removeEventListener('mouseup', endResize);
|
window.removeEventListener('mouseup', endResize)
|
||||||
window.removeEventListener('touchmove', handleTouchMove);
|
window.removeEventListener('touchmove', handleTouchMove)
|
||||||
window.removeEventListener('touchend', handleTouchEnd);
|
window.removeEventListener('touchend', handleTouchEnd)
|
||||||
});
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<NodeViewWrapper
|
<NodeViewWrapper
|
||||||
|
|
@ -136,10 +136,10 @@
|
||||||
aria-label="Resize left"
|
aria-label="Resize left"
|
||||||
class="edra-media-resize-handle edra-media-resize-handle-left"
|
class="edra-media-resize-handle edra-media-resize-handle-left"
|
||||||
onmousedown={(event: MouseEvent) => {
|
onmousedown={(event: MouseEvent) => {
|
||||||
handleResizingPosition(event, 'left');
|
handleResizingPosition(event, 'left')
|
||||||
}}
|
}}
|
||||||
ontouchstart={(event: TouchEvent) => {
|
ontouchstart={(event: TouchEvent) => {
|
||||||
handleTouchStart(event, 'left');
|
handleTouchStart(event, 'left')
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div class="edra-media-resize-indicator"></div>
|
<div class="edra-media-resize-indicator"></div>
|
||||||
|
|
@ -151,10 +151,10 @@
|
||||||
aria-label="Resize right"
|
aria-label="Resize right"
|
||||||
class="edra-media-resize-handle edra-media-resize-handle-right"
|
class="edra-media-resize-handle edra-media-resize-handle-right"
|
||||||
onmousedown={(event: MouseEvent) => {
|
onmousedown={(event: MouseEvent) => {
|
||||||
handleResizingPosition(event, 'right');
|
handleResizingPosition(event, 'right')
|
||||||
}}
|
}}
|
||||||
ontouchstart={(event: TouchEvent) => {
|
ontouchstart={(event: TouchEvent) => {
|
||||||
handleTouchStart(event, 'right');
|
handleTouchStart(event, 'right')
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div class="edra-media-resize-indicator"></div>
|
<div class="edra-media-resize-indicator"></div>
|
||||||
|
|
@ -185,7 +185,7 @@
|
||||||
<button
|
<button
|
||||||
class="edra-toolbar-button"
|
class="edra-toolbar-button"
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
if (caption === null || caption.trim() === '') caption = 'Audio Caption';
|
if (caption === null || caption.trim() === '') caption = 'Audio Caption'
|
||||||
}}
|
}}
|
||||||
title="Caption"
|
title="Caption"
|
||||||
>
|
>
|
||||||
|
|
@ -194,7 +194,7 @@
|
||||||
<button
|
<button
|
||||||
class="edra-toolbar-button"
|
class="edra-toolbar-button"
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
duplicateContent(editor, node);
|
duplicateContent(editor, node)
|
||||||
}}
|
}}
|
||||||
title="Duplicate"
|
title="Duplicate"
|
||||||
>
|
>
|
||||||
|
|
@ -205,7 +205,7 @@
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
updateAttributes({
|
updateAttributes({
|
||||||
width: 'fit-content'
|
width: 'fit-content'
|
||||||
});
|
})
|
||||||
}}
|
}}
|
||||||
title="Full Screen"
|
title="Full Screen"
|
||||||
>
|
>
|
||||||
|
|
@ -214,7 +214,7 @@
|
||||||
<button
|
<button
|
||||||
class="edra-toolbar-button edra-destructive"
|
class="edra-toolbar-button edra-destructive"
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
deleteNode();
|
deleteNode()
|
||||||
}}
|
}}
|
||||||
title="Delete"
|
title="Delete"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,17 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { NodeViewProps } from '@tiptap/core';
|
import type { NodeViewProps } from '@tiptap/core'
|
||||||
import AudioLines from 'lucide-svelte/icons/audio-lines';
|
import AudioLines from 'lucide-svelte/icons/audio-lines'
|
||||||
import { NodeViewWrapper } from 'svelte-tiptap';
|
import { NodeViewWrapper } from 'svelte-tiptap'
|
||||||
const { editor }: NodeViewProps = $props();
|
const { editor }: NodeViewProps = $props()
|
||||||
|
|
||||||
function handleClick(e: MouseEvent) {
|
function handleClick(e: MouseEvent) {
|
||||||
if (!editor.isEditable) return;
|
if (!editor.isEditable) return
|
||||||
e.preventDefault();
|
e.preventDefault()
|
||||||
const audioUrl = prompt('Enter the URL of an audio:');
|
const audioUrl = prompt('Enter the URL of an audio:')
|
||||||
if (!audioUrl) {
|
if (!audioUrl) {
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
editor.chain().focus().setAudio(audioUrl).run();
|
editor.chain().focus().setAudio(audioUrl).run()
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,28 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { NodeViewWrapper, NodeViewContent } from 'svelte-tiptap';
|
import { NodeViewWrapper, NodeViewContent } from 'svelte-tiptap'
|
||||||
import type { NodeViewProps } from '@tiptap/core';
|
import type { NodeViewProps } from '@tiptap/core'
|
||||||
const { node, updateAttributes, extension }: NodeViewProps = $props();
|
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(() => {
|
$effect(() => {
|
||||||
updateAttributes({ language: defaultLanguage });
|
updateAttributes({ language: defaultLanguage })
|
||||||
});
|
})
|
||||||
|
|
||||||
function copyCode() {
|
function copyCode() {
|
||||||
if (isCopying) return;
|
if (isCopying) return
|
||||||
if (!preRef) return;
|
if (!preRef) return
|
||||||
isCopying = true;
|
isCopying = true
|
||||||
navigator.clipboard.writeText(preRef.innerText);
|
navigator.clipboard.writeText(preRef.innerText)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
isCopying = false;
|
isCopying = false
|
||||||
}, 1000);
|
}, 1000)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,27 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { EdraCommand } from '../../commands/types.js';
|
import type { EdraCommand } from '../../commands/types.js'
|
||||||
import type { Editor } from '@tiptap/core';
|
import type { Editor } from '@tiptap/core'
|
||||||
import { icons } from 'lucide-svelte';
|
import { icons } from 'lucide-svelte'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
command: EdraCommand;
|
command: EdraCommand
|
||||||
editor: Editor;
|
editor: Editor
|
||||||
style?: string;
|
style?: string
|
||||||
onclick?: () => void;
|
onclick?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const { command, editor, style, onclick }: Props = $props();
|
const { command, editor, style, onclick }: Props = $props()
|
||||||
|
|
||||||
const Icon = icons[command.iconName];
|
const Icon = icons[command.iconName]
|
||||||
const shortcut = command.shortCuts ? ` (${command.shortCuts[0]})` : '';
|
const shortcut = command.shortCuts ? ` (${command.shortCuts[0]})` : ''
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="edra-command-button"
|
class="edra-command-button"
|
||||||
class:active={editor.isActive(command.name) || command.isActive?.(editor)}
|
class:active={editor.isActive(command.name) || command.isActive?.(editor)}
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
if (onclick !== undefined) onclick();
|
if (onclick !== undefined) onclick()
|
||||||
else command.action(editor);
|
else command.action(editor)
|
||||||
}}
|
}}
|
||||||
title={`${command.label}${shortcut}`}
|
title={`${command.label}${shortcut}`}
|
||||||
{style}
|
{style}
|
||||||
|
|
|
||||||
|
|
@ -1,112 +1,112 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy, onMount } from 'svelte';
|
import { onDestroy, onMount } from 'svelte'
|
||||||
import { NodeViewWrapper } from 'svelte-tiptap';
|
import { NodeViewWrapper } from 'svelte-tiptap'
|
||||||
import type { NodeViewProps } from '@tiptap/core';
|
import type { NodeViewProps } from '@tiptap/core'
|
||||||
|
|
||||||
import AlignLeft from 'lucide-svelte/icons/align-left';
|
import AlignLeft from 'lucide-svelte/icons/align-left'
|
||||||
import AlignCenter from 'lucide-svelte/icons/align-center';
|
import AlignCenter from 'lucide-svelte/icons/align-center'
|
||||||
import AlignRight from 'lucide-svelte/icons/align-right';
|
import AlignRight from 'lucide-svelte/icons/align-right'
|
||||||
import CopyIcon from 'lucide-svelte/icons/copy';
|
import CopyIcon from 'lucide-svelte/icons/copy'
|
||||||
import Fullscreen from 'lucide-svelte/icons/fullscreen';
|
import Fullscreen from 'lucide-svelte/icons/fullscreen'
|
||||||
import Trash from 'lucide-svelte/icons/trash';
|
import Trash from 'lucide-svelte/icons/trash'
|
||||||
import Captions from 'lucide-svelte/icons/captions';
|
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 iframeRef: HTMLIFrameElement
|
||||||
let nodeRef: HTMLDivElement;
|
let nodeRef: HTMLDivElement
|
||||||
|
|
||||||
let caption: string | null = $state(node.attrs.title);
|
let caption: string | null = $state(node.attrs.title)
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (caption?.trim() === '') caption = null;
|
if (caption?.trim() === '') caption = null
|
||||||
updateAttributes({ title: caption });
|
updateAttributes({ title: caption })
|
||||||
});
|
})
|
||||||
|
|
||||||
let resizing = $state(false);
|
let resizing = $state(false)
|
||||||
let resizingInitialWidth = $state(0);
|
let resizingInitialWidth = $state(0)
|
||||||
let resizingInitialMouseX = $state(0);
|
let resizingInitialMouseX = $state(0)
|
||||||
let resizingPosition = $state<'left' | 'right'>('left');
|
let resizingPosition = $state<'left' | 'right'>('left')
|
||||||
|
|
||||||
function handleResizingPosition(e: MouseEvent, position: 'left' | 'right') {
|
function handleResizingPosition(e: MouseEvent, position: 'left' | 'right') {
|
||||||
startResize(e);
|
startResize(e)
|
||||||
resizingPosition = position;
|
resizingPosition = position
|
||||||
}
|
}
|
||||||
|
|
||||||
function startResize(e: MouseEvent) {
|
function startResize(e: MouseEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault()
|
||||||
resizing = true;
|
resizing = true
|
||||||
resizingInitialMouseX = e.clientX;
|
resizingInitialMouseX = e.clientX
|
||||||
if (iframeRef) resizingInitialWidth = iframeRef.offsetWidth;
|
if (iframeRef) resizingInitialWidth = iframeRef.offsetWidth
|
||||||
}
|
}
|
||||||
|
|
||||||
function resize(e: MouseEvent) {
|
function resize(e: MouseEvent) {
|
||||||
if (!resizing) return;
|
if (!resizing) return
|
||||||
let dx = e.clientX - resizingInitialMouseX;
|
let dx = e.clientX - resizingInitialMouseX
|
||||||
if (resizingPosition === 'left') {
|
if (resizingPosition === 'left') {
|
||||||
dx = resizingInitialMouseX - e.clientX;
|
dx = resizingInitialMouseX - e.clientX
|
||||||
}
|
}
|
||||||
const newWidth = Math.max(resizingInitialWidth + dx, minWidth);
|
const newWidth = Math.max(resizingInitialWidth + dx, minWidth)
|
||||||
const parentWidth = nodeRef?.parentElement?.offsetWidth || 0;
|
const parentWidth = nodeRef?.parentElement?.offsetWidth || 0
|
||||||
if (newWidth < parentWidth) {
|
if (newWidth < parentWidth) {
|
||||||
updateAttributes({ width: newWidth });
|
updateAttributes({ width: newWidth })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function endResize() {
|
function endResize() {
|
||||||
resizing = false;
|
resizing = false
|
||||||
resizingInitialMouseX = 0;
|
resizingInitialMouseX = 0
|
||||||
resizingInitialWidth = 0;
|
resizingInitialWidth = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleTouchStart(e: TouchEvent, position: 'left' | 'right') {
|
function handleTouchStart(e: TouchEvent, position: 'left' | 'right') {
|
||||||
e.preventDefault();
|
e.preventDefault()
|
||||||
resizing = true;
|
resizing = true
|
||||||
resizingPosition = position;
|
resizingPosition = position
|
||||||
resizingInitialMouseX = e.touches[0].clientX;
|
resizingInitialMouseX = e.touches[0].clientX
|
||||||
if (iframeRef) resizingInitialWidth = iframeRef.offsetWidth;
|
if (iframeRef) resizingInitialWidth = iframeRef.offsetWidth
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleTouchMove(e: TouchEvent) {
|
function handleTouchMove(e: TouchEvent) {
|
||||||
if (!resizing) return;
|
if (!resizing) return
|
||||||
let dx = e.touches[0].clientX - resizingInitialMouseX;
|
let dx = e.touches[0].clientX - resizingInitialMouseX
|
||||||
if (resizingPosition === 'left') {
|
if (resizingPosition === 'left') {
|
||||||
dx = resizingInitialMouseX - e.touches[0].clientX;
|
dx = resizingInitialMouseX - e.touches[0].clientX
|
||||||
}
|
}
|
||||||
const newWidth = Math.max(resizingInitialWidth + dx, minWidth);
|
const newWidth = Math.max(resizingInitialWidth + dx, minWidth)
|
||||||
const parentWidth = nodeRef?.parentElement?.offsetWidth || 0;
|
const parentWidth = nodeRef?.parentElement?.offsetWidth || 0
|
||||||
if (newWidth < parentWidth) {
|
if (newWidth < parentWidth) {
|
||||||
updateAttributes({ width: newWidth });
|
updateAttributes({ width: newWidth })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleTouchEnd() {
|
function handleTouchEnd() {
|
||||||
resizing = false;
|
resizing = false
|
||||||
resizingInitialMouseX = 0;
|
resizingInitialMouseX = 0
|
||||||
resizingInitialWidth = 0;
|
resizingInitialWidth = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
// Attach id to nodeRef
|
// Attach id to nodeRef
|
||||||
nodeRef = document.getElementById('resizable-container-audio') as HTMLDivElement;
|
nodeRef = document.getElementById('resizable-container-audio') as HTMLDivElement
|
||||||
|
|
||||||
// Mouse events
|
// Mouse events
|
||||||
window.addEventListener('mousemove', resize);
|
window.addEventListener('mousemove', resize)
|
||||||
window.addEventListener('mouseup', endResize);
|
window.addEventListener('mouseup', endResize)
|
||||||
// Touch events
|
// Touch events
|
||||||
window.addEventListener('touchmove', handleTouchMove);
|
window.addEventListener('touchmove', handleTouchMove)
|
||||||
window.addEventListener('touchend', handleTouchEnd);
|
window.addEventListener('touchend', handleTouchEnd)
|
||||||
});
|
})
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
window.removeEventListener('mousemove', resize);
|
window.removeEventListener('mousemove', resize)
|
||||||
window.removeEventListener('mouseup', endResize);
|
window.removeEventListener('mouseup', endResize)
|
||||||
window.removeEventListener('touchmove', handleTouchMove);
|
window.removeEventListener('touchmove', handleTouchMove)
|
||||||
window.removeEventListener('touchend', handleTouchEnd);
|
window.removeEventListener('touchend', handleTouchEnd)
|
||||||
});
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<NodeViewWrapper
|
<NodeViewWrapper
|
||||||
|
|
@ -130,10 +130,10 @@
|
||||||
aria-label="Resize left"
|
aria-label="Resize left"
|
||||||
class="edra-media-resize-handle edra-media-resize-handle-left"
|
class="edra-media-resize-handle edra-media-resize-handle-left"
|
||||||
onmousedown={(event: MouseEvent) => {
|
onmousedown={(event: MouseEvent) => {
|
||||||
handleResizingPosition(event, 'left');
|
handleResizingPosition(event, 'left')
|
||||||
}}
|
}}
|
||||||
ontouchstart={(event: TouchEvent) => {
|
ontouchstart={(event: TouchEvent) => {
|
||||||
handleTouchStart(event, 'left');
|
handleTouchStart(event, 'left')
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div class="edra-media-resize-indicator"></div>
|
<div class="edra-media-resize-indicator"></div>
|
||||||
|
|
@ -145,10 +145,10 @@
|
||||||
aria-label="Resize right"
|
aria-label="Resize right"
|
||||||
class="edra-media-resize-handle edra-media-resize-handle-right"
|
class="edra-media-resize-handle edra-media-resize-handle-right"
|
||||||
onmousedown={(event: MouseEvent) => {
|
onmousedown={(event: MouseEvent) => {
|
||||||
handleResizingPosition(event, 'right');
|
handleResizingPosition(event, 'right')
|
||||||
}}
|
}}
|
||||||
ontouchstart={(event: TouchEvent) => {
|
ontouchstart={(event: TouchEvent) => {
|
||||||
handleTouchStart(event, 'right');
|
handleTouchStart(event, 'right')
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div class="edra-media-resize-indicator"></div>
|
<div class="edra-media-resize-indicator"></div>
|
||||||
|
|
@ -179,7 +179,7 @@
|
||||||
<button
|
<button
|
||||||
class="edra-toolbar-button"
|
class="edra-toolbar-button"
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
if (caption === null || caption.trim() === '') caption = 'Audio Caption';
|
if (caption === null || caption.trim() === '') caption = 'Audio Caption'
|
||||||
}}
|
}}
|
||||||
title="Caption"
|
title="Caption"
|
||||||
>
|
>
|
||||||
|
|
@ -188,7 +188,7 @@
|
||||||
<button
|
<button
|
||||||
class="edra-toolbar-button"
|
class="edra-toolbar-button"
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
duplicateContent(editor, node);
|
duplicateContent(editor, node)
|
||||||
}}
|
}}
|
||||||
title="Duplicate"
|
title="Duplicate"
|
||||||
>
|
>
|
||||||
|
|
@ -199,7 +199,7 @@
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
updateAttributes({
|
updateAttributes({
|
||||||
width: 'fit-content'
|
width: 'fit-content'
|
||||||
});
|
})
|
||||||
}}
|
}}
|
||||||
title="Full Screen"
|
title="Full Screen"
|
||||||
>
|
>
|
||||||
|
|
@ -208,7 +208,7 @@
|
||||||
<button
|
<button
|
||||||
class="edra-toolbar-button edra-destructive"
|
class="edra-toolbar-button edra-destructive"
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
deleteNode();
|
deleteNode()
|
||||||
}}
|
}}
|
||||||
title="Delete"
|
title="Delete"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,17 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { NodeViewProps } from '@tiptap/core';
|
import type { NodeViewProps } from '@tiptap/core'
|
||||||
import CodeXML from 'lucide-svelte/icons/code-xml';
|
import CodeXML from 'lucide-svelte/icons/code-xml'
|
||||||
import { NodeViewWrapper } from 'svelte-tiptap';
|
import { NodeViewWrapper } from 'svelte-tiptap'
|
||||||
const { editor }: NodeViewProps = $props();
|
const { editor }: NodeViewProps = $props()
|
||||||
|
|
||||||
function handleClick(e: MouseEvent) {
|
function handleClick(e: MouseEvent) {
|
||||||
if (!editor.isEditable) return;
|
if (!editor.isEditable) return
|
||||||
e.preventDefault();
|
e.preventDefault()
|
||||||
const iFrameURL = prompt('Enter the URL of an iFrame:');
|
const iFrameURL = prompt('Enter the URL of an iFrame:')
|
||||||
if (!iFrameURL) {
|
if (!iFrameURL) {
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
editor.chain().focus().setIframe({ src: iFrameURL }).run();
|
editor.chain().focus().setIframe({ src: iFrameURL }).run()
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,112 +1,112 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy, onMount } from 'svelte';
|
import { onDestroy, onMount } from 'svelte'
|
||||||
import { NodeViewWrapper } from 'svelte-tiptap';
|
import { NodeViewWrapper } from 'svelte-tiptap'
|
||||||
import type { NodeViewProps } from '@tiptap/core';
|
import type { NodeViewProps } from '@tiptap/core'
|
||||||
|
|
||||||
import AlignLeft from 'lucide-svelte/icons/align-left';
|
import AlignLeft from 'lucide-svelte/icons/align-left'
|
||||||
import AlignCenter from 'lucide-svelte/icons/align-center';
|
import AlignCenter from 'lucide-svelte/icons/align-center'
|
||||||
import AlignRight from 'lucide-svelte/icons/align-right';
|
import AlignRight from 'lucide-svelte/icons/align-right'
|
||||||
import CopyIcon from 'lucide-svelte/icons/copy';
|
import CopyIcon from 'lucide-svelte/icons/copy'
|
||||||
import Fullscreen from 'lucide-svelte/icons/fullscreen';
|
import Fullscreen from 'lucide-svelte/icons/fullscreen'
|
||||||
import Trash from 'lucide-svelte/icons/trash';
|
import Trash from 'lucide-svelte/icons/trash'
|
||||||
import Captions from 'lucide-svelte/icons/captions';
|
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 imgRef: HTMLImageElement
|
||||||
let nodeRef: HTMLDivElement;
|
let nodeRef: HTMLDivElement
|
||||||
|
|
||||||
let caption: string | null = $state(node.attrs.title);
|
let caption: string | null = $state(node.attrs.title)
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (caption?.trim() === '') caption = null;
|
if (caption?.trim() === '') caption = null
|
||||||
updateAttributes({ title: caption });
|
updateAttributes({ title: caption })
|
||||||
});
|
})
|
||||||
|
|
||||||
let resizing = $state(false);
|
let resizing = $state(false)
|
||||||
let resizingInitialWidth = $state(0);
|
let resizingInitialWidth = $state(0)
|
||||||
let resizingInitialMouseX = $state(0);
|
let resizingInitialMouseX = $state(0)
|
||||||
let resizingPosition = $state<'left' | 'right'>('left');
|
let resizingPosition = $state<'left' | 'right'>('left')
|
||||||
|
|
||||||
function handleResizingPosition(e: MouseEvent, position: 'left' | 'right') {
|
function handleResizingPosition(e: MouseEvent, position: 'left' | 'right') {
|
||||||
startResize(e);
|
startResize(e)
|
||||||
resizingPosition = position;
|
resizingPosition = position
|
||||||
}
|
}
|
||||||
|
|
||||||
function startResize(e: MouseEvent) {
|
function startResize(e: MouseEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault()
|
||||||
resizing = true;
|
resizing = true
|
||||||
resizingInitialMouseX = e.clientX;
|
resizingInitialMouseX = e.clientX
|
||||||
if (imgRef) resizingInitialWidth = imgRef.offsetWidth;
|
if (imgRef) resizingInitialWidth = imgRef.offsetWidth
|
||||||
}
|
}
|
||||||
|
|
||||||
function resize(e: MouseEvent) {
|
function resize(e: MouseEvent) {
|
||||||
if (!resizing) return;
|
if (!resizing) return
|
||||||
let dx = e.clientX - resizingInitialMouseX;
|
let dx = e.clientX - resizingInitialMouseX
|
||||||
if (resizingPosition === 'left') {
|
if (resizingPosition === 'left') {
|
||||||
dx = resizingInitialMouseX - e.clientX;
|
dx = resizingInitialMouseX - e.clientX
|
||||||
}
|
}
|
||||||
const newWidth = Math.max(resizingInitialWidth + dx, minWidth);
|
const newWidth = Math.max(resizingInitialWidth + dx, minWidth)
|
||||||
const parentWidth = nodeRef?.parentElement?.offsetWidth || 0;
|
const parentWidth = nodeRef?.parentElement?.offsetWidth || 0
|
||||||
if (newWidth < parentWidth) {
|
if (newWidth < parentWidth) {
|
||||||
updateAttributes({ width: newWidth });
|
updateAttributes({ width: newWidth })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function endResize() {
|
function endResize() {
|
||||||
resizing = false;
|
resizing = false
|
||||||
resizingInitialMouseX = 0;
|
resizingInitialMouseX = 0
|
||||||
resizingInitialWidth = 0;
|
resizingInitialWidth = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleTouchStart(e: TouchEvent, position: 'left' | 'right') {
|
function handleTouchStart(e: TouchEvent, position: 'left' | 'right') {
|
||||||
e.preventDefault();
|
e.preventDefault()
|
||||||
resizing = true;
|
resizing = true
|
||||||
resizingPosition = position;
|
resizingPosition = position
|
||||||
resizingInitialMouseX = e.touches[0].clientX;
|
resizingInitialMouseX = e.touches[0].clientX
|
||||||
if (imgRef) resizingInitialWidth = imgRef.offsetWidth;
|
if (imgRef) resizingInitialWidth = imgRef.offsetWidth
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleTouchMove(e: TouchEvent) {
|
function handleTouchMove(e: TouchEvent) {
|
||||||
if (!resizing) return;
|
if (!resizing) return
|
||||||
let dx = e.touches[0].clientX - resizingInitialMouseX;
|
let dx = e.touches[0].clientX - resizingInitialMouseX
|
||||||
if (resizingPosition === 'left') {
|
if (resizingPosition === 'left') {
|
||||||
dx = resizingInitialMouseX - e.touches[0].clientX;
|
dx = resizingInitialMouseX - e.touches[0].clientX
|
||||||
}
|
}
|
||||||
const newWidth = Math.max(resizingInitialWidth + dx, minWidth);
|
const newWidth = Math.max(resizingInitialWidth + dx, minWidth)
|
||||||
const parentWidth = nodeRef?.parentElement?.offsetWidth || 0;
|
const parentWidth = nodeRef?.parentElement?.offsetWidth || 0
|
||||||
if (newWidth < parentWidth) {
|
if (newWidth < parentWidth) {
|
||||||
updateAttributes({ width: newWidth });
|
updateAttributes({ width: newWidth })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleTouchEnd() {
|
function handleTouchEnd() {
|
||||||
resizing = false;
|
resizing = false
|
||||||
resizingInitialMouseX = 0;
|
resizingInitialMouseX = 0
|
||||||
resizingInitialWidth = 0;
|
resizingInitialWidth = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
// Attach id to nodeRef
|
// Attach id to nodeRef
|
||||||
nodeRef = document.getElementById('resizable-container-media') as HTMLDivElement;
|
nodeRef = document.getElementById('resizable-container-media') as HTMLDivElement
|
||||||
|
|
||||||
// Mouse events
|
// Mouse events
|
||||||
window.addEventListener('mousemove', resize);
|
window.addEventListener('mousemove', resize)
|
||||||
window.addEventListener('mouseup', endResize);
|
window.addEventListener('mouseup', endResize)
|
||||||
// Touch events
|
// Touch events
|
||||||
window.addEventListener('touchmove', handleTouchMove);
|
window.addEventListener('touchmove', handleTouchMove)
|
||||||
window.addEventListener('touchend', handleTouchEnd);
|
window.addEventListener('touchend', handleTouchEnd)
|
||||||
});
|
})
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
window.removeEventListener('mousemove', resize);
|
window.removeEventListener('mousemove', resize)
|
||||||
window.removeEventListener('mouseup', endResize);
|
window.removeEventListener('mouseup', endResize)
|
||||||
window.removeEventListener('touchmove', handleTouchMove);
|
window.removeEventListener('touchmove', handleTouchMove)
|
||||||
window.removeEventListener('touchend', handleTouchEnd);
|
window.removeEventListener('touchend', handleTouchEnd)
|
||||||
});
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<NodeViewWrapper
|
<NodeViewWrapper
|
||||||
|
|
@ -133,10 +133,10 @@
|
||||||
aria-label="Resize left"
|
aria-label="Resize left"
|
||||||
class="edra-media-resize-handle edra-media-resize-handle-left"
|
class="edra-media-resize-handle edra-media-resize-handle-left"
|
||||||
onmousedown={(event: MouseEvent) => {
|
onmousedown={(event: MouseEvent) => {
|
||||||
handleResizingPosition(event, 'left');
|
handleResizingPosition(event, 'left')
|
||||||
}}
|
}}
|
||||||
ontouchstart={(event: TouchEvent) => {
|
ontouchstart={(event: TouchEvent) => {
|
||||||
handleTouchStart(event, 'left');
|
handleTouchStart(event, 'left')
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div class="edra-media-resize-indicator"></div>
|
<div class="edra-media-resize-indicator"></div>
|
||||||
|
|
@ -148,10 +148,10 @@
|
||||||
aria-label="Resize right"
|
aria-label="Resize right"
|
||||||
class="edra-media-resize-handle edra-media-resize-handle-right"
|
class="edra-media-resize-handle edra-media-resize-handle-right"
|
||||||
onmousedown={(event: MouseEvent) => {
|
onmousedown={(event: MouseEvent) => {
|
||||||
handleResizingPosition(event, 'right');
|
handleResizingPosition(event, 'right')
|
||||||
}}
|
}}
|
||||||
ontouchstart={(event: TouchEvent) => {
|
ontouchstart={(event: TouchEvent) => {
|
||||||
handleTouchStart(event, 'right');
|
handleTouchStart(event, 'right')
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div class="edra-media-resize-indicator"></div>
|
<div class="edra-media-resize-indicator"></div>
|
||||||
|
|
@ -182,7 +182,7 @@
|
||||||
<button
|
<button
|
||||||
class="edra-toolbar-button"
|
class="edra-toolbar-button"
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
if (caption === null || caption.trim() === '') caption = 'Image Caption';
|
if (caption === null || caption.trim() === '') caption = 'Image Caption'
|
||||||
}}
|
}}
|
||||||
title="Caption"
|
title="Caption"
|
||||||
>
|
>
|
||||||
|
|
@ -191,7 +191,7 @@
|
||||||
<button
|
<button
|
||||||
class="edra-toolbar-button"
|
class="edra-toolbar-button"
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
duplicateContent(editor, node);
|
duplicateContent(editor, node)
|
||||||
}}
|
}}
|
||||||
title="Duplicate"
|
title="Duplicate"
|
||||||
>
|
>
|
||||||
|
|
@ -202,7 +202,7 @@
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
updateAttributes({
|
updateAttributes({
|
||||||
width: 'fit-content'
|
width: 'fit-content'
|
||||||
});
|
})
|
||||||
}}
|
}}
|
||||||
title="Full Screen"
|
title="Full Screen"
|
||||||
>
|
>
|
||||||
|
|
@ -211,7 +211,7 @@
|
||||||
<button
|
<button
|
||||||
class="edra-toolbar-button edra-destructive"
|
class="edra-toolbar-button edra-destructive"
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
deleteNode();
|
deleteNode()
|
||||||
}}
|
}}
|
||||||
title="Delete"
|
title="Delete"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,17 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { NodeViewProps } from '@tiptap/core';
|
import type { NodeViewProps } from '@tiptap/core'
|
||||||
import Image from 'lucide-svelte/icons/image';
|
import Image from 'lucide-svelte/icons/image'
|
||||||
import { NodeViewWrapper } from 'svelte-tiptap';
|
import { NodeViewWrapper } from 'svelte-tiptap'
|
||||||
const { editor }: NodeViewProps = $props();
|
const { editor }: NodeViewProps = $props()
|
||||||
|
|
||||||
function handleClick(e: MouseEvent) {
|
function handleClick(e: MouseEvent) {
|
||||||
if (!editor.isEditable) return;
|
if (!editor.isEditable) return
|
||||||
e.preventDefault();
|
e.preventDefault()
|
||||||
const imageUrl = prompt('Enter the URL of an image:');
|
const imageUrl = prompt('Enter the URL of an image:')
|
||||||
if (!imageUrl) {
|
if (!imageUrl) {
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
editor.chain().focus().setImage({ src: imageUrl }).run();
|
editor.chain().focus().setImage({ src: imageUrl }).run()
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,75 +1,75 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Editor } from '@tiptap/core';
|
import type { Editor } from '@tiptap/core'
|
||||||
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
|
import ArrowLeft from 'lucide-svelte/icons/arrow-left'
|
||||||
import ArrowRight from 'lucide-svelte/icons/arrow-right';
|
import ArrowRight from 'lucide-svelte/icons/arrow-right'
|
||||||
import CaseSensitive from 'lucide-svelte/icons/case-sensitive';
|
import CaseSensitive from 'lucide-svelte/icons/case-sensitive'
|
||||||
import Replace from 'lucide-svelte/icons/replace';
|
import Replace from 'lucide-svelte/icons/replace'
|
||||||
import ReplaceAll from 'lucide-svelte/icons/replace-all';
|
import ReplaceAll from 'lucide-svelte/icons/replace-all'
|
||||||
import Search from 'lucide-svelte/icons/search';
|
import Search from 'lucide-svelte/icons/search'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
editor: Editor;
|
editor: Editor
|
||||||
show: boolean;
|
show: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
let { editor, show = $bindable(false) }: Props = $props();
|
let { editor, show = $bindable(false) }: Props = $props()
|
||||||
|
|
||||||
let searchText = $state('');
|
let searchText = $state('')
|
||||||
let replaceText = $state('');
|
let replaceText = $state('')
|
||||||
let caseSensitive = $state(false);
|
let caseSensitive = $state(false)
|
||||||
|
|
||||||
let searchIndex = $derived(editor.storage?.searchAndReplace?.resultIndex);
|
let searchIndex = $derived(editor.storage?.searchAndReplace?.resultIndex)
|
||||||
let searchCount = $derived(editor.storage?.searchAndReplace?.results.length);
|
let searchCount = $derived(editor.storage?.searchAndReplace?.results.length)
|
||||||
|
|
||||||
function updateSearchTerm(clearIndex: boolean = false) {
|
function updateSearchTerm(clearIndex: boolean = false) {
|
||||||
if (clearIndex) editor.commands.resetIndex();
|
if (clearIndex) editor.commands.resetIndex()
|
||||||
|
|
||||||
editor.commands.setSearchTerm(searchText);
|
editor.commands.setSearchTerm(searchText)
|
||||||
editor.commands.setReplaceTerm(replaceText);
|
editor.commands.setReplaceTerm(replaceText)
|
||||||
editor.commands.setCaseSensitive(caseSensitive);
|
editor.commands.setCaseSensitive(caseSensitive)
|
||||||
}
|
}
|
||||||
|
|
||||||
function goToSelection() {
|
function goToSelection() {
|
||||||
const { results, resultIndex } = editor.storage.searchAndReplace;
|
const { results, resultIndex } = editor.storage.searchAndReplace
|
||||||
const position = results[resultIndex];
|
const position = results[resultIndex]
|
||||||
if (!position) return;
|
if (!position) return
|
||||||
editor.commands.setTextSelection(position);
|
editor.commands.setTextSelection(position)
|
||||||
const { node } = editor.view.domAtPos(editor.state.selection.anchor);
|
const { node } = editor.view.domAtPos(editor.state.selection.anchor)
|
||||||
if (node instanceof HTMLElement) node.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
if (node instanceof HTMLElement) node.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||||
}
|
}
|
||||||
|
|
||||||
function replace() {
|
function replace() {
|
||||||
editor.commands.replace();
|
editor.commands.replace()
|
||||||
goToSelection();
|
goToSelection()
|
||||||
}
|
}
|
||||||
|
|
||||||
const next = () => {
|
const next = () => {
|
||||||
editor.commands.nextSearchResult();
|
editor.commands.nextSearchResult()
|
||||||
goToSelection();
|
goToSelection()
|
||||||
};
|
}
|
||||||
|
|
||||||
const previous = () => {
|
const previous = () => {
|
||||||
editor.commands.previousSearchResult();
|
editor.commands.previousSearchResult()
|
||||||
goToSelection();
|
goToSelection()
|
||||||
};
|
}
|
||||||
|
|
||||||
const clear = () => {
|
const clear = () => {
|
||||||
searchText = '';
|
searchText = ''
|
||||||
replaceText = '';
|
replaceText = ''
|
||||||
caseSensitive = false;
|
caseSensitive = false
|
||||||
editor.commands.resetIndex();
|
editor.commands.resetIndex()
|
||||||
};
|
}
|
||||||
|
|
||||||
const replaceAll = () => editor.commands.replaceAll();
|
const replaceAll = () => editor.commands.replaceAll()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="edra-search-and-replace">
|
<div class="edra-search-and-replace">
|
||||||
<button
|
<button
|
||||||
class="edra-command-button"
|
class="edra-command-button"
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
show = !show;
|
show = !show
|
||||||
clear();
|
clear()
|
||||||
updateSearchTerm();
|
updateSearchTerm()
|
||||||
}}
|
}}
|
||||||
title={show ? 'Go Back' : 'Search and Replace'}
|
title={show ? 'Go Back' : 'Search and Replace'}
|
||||||
>
|
>
|
||||||
|
|
@ -87,8 +87,8 @@
|
||||||
class="edra-command-button"
|
class="edra-command-button"
|
||||||
class:active={caseSensitive}
|
class:active={caseSensitive}
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
caseSensitive = !caseSensitive;
|
caseSensitive = !caseSensitive
|
||||||
updateSearchTerm();
|
updateSearchTerm()
|
||||||
}}
|
}}
|
||||||
title="Case Sensitive"
|
title="Case Sensitive"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -1,92 +1,92 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { icons } from 'lucide-svelte';
|
import { icons } from 'lucide-svelte'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// 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 selectedGroupIndex = $state<number>(0)
|
||||||
let selectedCommandIndex = $state<number>(0);
|
let selectedCommandIndex = $state<number>(0)
|
||||||
|
|
||||||
const items = $derived.by(() => props.items);
|
const items = $derived.by(() => props.items)
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (items) {
|
if (items) {
|
||||||
selectedGroupIndex = 0;
|
selectedGroupIndex = 0
|
||||||
selectedCommandIndex = 0;
|
selectedCommandIndex = 0
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const activeItem = document.getElementById(`${selectedGroupIndex}-${selectedCommandIndex}`);
|
const activeItem = document.getElementById(`${selectedGroupIndex}-${selectedCommandIndex}`)
|
||||||
if (activeItem !== null && scrollContainer !== null) {
|
if (activeItem !== null && scrollContainer !== null) {
|
||||||
const offsetTop = activeItem.offsetTop;
|
const offsetTop = activeItem.offsetTop
|
||||||
const offsetHeight = activeItem.offsetHeight;
|
const offsetHeight = activeItem.offsetHeight
|
||||||
scrollContainer.scrollTop = offsetTop - offsetHeight;
|
scrollContainer.scrollTop = offsetTop - offsetHeight
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
|
|
||||||
const selectItem = (groupIndex: number, commandIndex: number) => {
|
const selectItem = (groupIndex: number, commandIndex: number) => {
|
||||||
const command = props.items[groupIndex].commands[commandIndex];
|
const command = props.items[groupIndex].commands[commandIndex]
|
||||||
props.command(command);
|
props.command(command)
|
||||||
};
|
}
|
||||||
|
|
||||||
function handleKeyDown(e: KeyboardEvent) {
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
if (e.key === 'ArrowDown' || ((e.ctrlKey || e.metaKey) && e.key === 'j') || e.key === 'Tab') {
|
if (e.key === 'ArrowDown' || ((e.ctrlKey || e.metaKey) && e.key === 'j') || e.key === 'Tab') {
|
||||||
e.preventDefault();
|
e.preventDefault()
|
||||||
if (!props.items.length) {
|
if (!props.items.length) {
|
||||||
return false;
|
return false
|
||||||
}
|
}
|
||||||
const commands = props.items[selectedGroupIndex].commands;
|
const commands = props.items[selectedGroupIndex].commands
|
||||||
let newCommandIndex = selectedCommandIndex + 1;
|
let newCommandIndex = selectedCommandIndex + 1
|
||||||
let newGroupIndex = selectedGroupIndex;
|
let newGroupIndex = selectedGroupIndex
|
||||||
if (commands.length - 1 < newCommandIndex) {
|
if (commands.length - 1 < newCommandIndex) {
|
||||||
newCommandIndex = 0;
|
newCommandIndex = 0
|
||||||
newGroupIndex = selectedGroupIndex + 1;
|
newGroupIndex = selectedGroupIndex + 1
|
||||||
}
|
}
|
||||||
|
|
||||||
if (props.items.length - 1 < newGroupIndex) {
|
if (props.items.length - 1 < newGroupIndex) {
|
||||||
newGroupIndex = 0;
|
newGroupIndex = 0
|
||||||
}
|
}
|
||||||
selectedCommandIndex = newCommandIndex;
|
selectedCommandIndex = newCommandIndex
|
||||||
selectedGroupIndex = newGroupIndex;
|
selectedGroupIndex = newGroupIndex
|
||||||
return true;
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (e.key === 'ArrowUp' || ((e.ctrlKey || e.metaKey) && e.key === 'k')) {
|
if (e.key === 'ArrowUp' || ((e.ctrlKey || e.metaKey) && e.key === 'k')) {
|
||||||
e.preventDefault();
|
e.preventDefault()
|
||||||
if (!props.items.length) {
|
if (!props.items.length) {
|
||||||
return false;
|
return false
|
||||||
}
|
}
|
||||||
let newCommandIndex = selectedCommandIndex - 1;
|
let newCommandIndex = selectedCommandIndex - 1
|
||||||
let newGroupIndex = selectedGroupIndex;
|
let newGroupIndex = selectedGroupIndex
|
||||||
if (newCommandIndex < 0) {
|
if (newCommandIndex < 0) {
|
||||||
newGroupIndex = selectedGroupIndex - 1;
|
newGroupIndex = selectedGroupIndex - 1
|
||||||
newCommandIndex = props.items[newGroupIndex]?.commands.length - 1 || 0;
|
newCommandIndex = props.items[newGroupIndex]?.commands.length - 1 || 0
|
||||||
}
|
}
|
||||||
if (newGroupIndex < 0) {
|
if (newGroupIndex < 0) {
|
||||||
newGroupIndex = props.items.length - 1;
|
newGroupIndex = props.items.length - 1
|
||||||
newCommandIndex = props.items[newGroupIndex].commands.length - 1;
|
newCommandIndex = props.items[newGroupIndex].commands.length - 1
|
||||||
}
|
}
|
||||||
selectedCommandIndex = newCommandIndex;
|
selectedCommandIndex = newCommandIndex
|
||||||
selectedGroupIndex = newGroupIndex;
|
selectedGroupIndex = newGroupIndex
|
||||||
return true;
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
e.preventDefault();
|
e.preventDefault()
|
||||||
if (!props.items.length || selectedGroupIndex === -1 || selectedCommandIndex === -1) {
|
if (!props.items.length || selectedGroupIndex === -1 || selectedCommandIndex === -1) {
|
||||||
return false;
|
return false
|
||||||
}
|
}
|
||||||
selectItem(selectedGroupIndex, selectedCommandIndex);
|
selectItem(selectedGroupIndex, selectedCommandIndex)
|
||||||
return true;
|
return true
|
||||||
}
|
}
|
||||||
return false;
|
return false
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,112 +1,112 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy, onMount } from 'svelte';
|
import { onDestroy, onMount } from 'svelte'
|
||||||
import { NodeViewWrapper } from 'svelte-tiptap';
|
import { NodeViewWrapper } from 'svelte-tiptap'
|
||||||
import type { NodeViewProps } from '@tiptap/core';
|
import type { NodeViewProps } from '@tiptap/core'
|
||||||
|
|
||||||
import AlignLeft from 'lucide-svelte/icons/align-left';
|
import AlignLeft from 'lucide-svelte/icons/align-left'
|
||||||
import AlignCenter from 'lucide-svelte/icons/align-center';
|
import AlignCenter from 'lucide-svelte/icons/align-center'
|
||||||
import AlignRight from 'lucide-svelte/icons/align-right';
|
import AlignRight from 'lucide-svelte/icons/align-right'
|
||||||
import CopyIcon from 'lucide-svelte/icons/copy';
|
import CopyIcon from 'lucide-svelte/icons/copy'
|
||||||
import Fullscreen from 'lucide-svelte/icons/fullscreen';
|
import Fullscreen from 'lucide-svelte/icons/fullscreen'
|
||||||
import Trash from 'lucide-svelte/icons/trash';
|
import Trash from 'lucide-svelte/icons/trash'
|
||||||
import Captions from 'lucide-svelte/icons/captions';
|
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 vidRef: HTMLVideoElement
|
||||||
let nodeRef: HTMLDivElement;
|
let nodeRef: HTMLDivElement
|
||||||
|
|
||||||
let caption: string | null = $state(node.attrs.title);
|
let caption: string | null = $state(node.attrs.title)
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (caption?.trim() === '') caption = null;
|
if (caption?.trim() === '') caption = null
|
||||||
updateAttributes({ title: caption });
|
updateAttributes({ title: caption })
|
||||||
});
|
})
|
||||||
|
|
||||||
let resizing = $state(false);
|
let resizing = $state(false)
|
||||||
let resizingInitialWidth = $state(0);
|
let resizingInitialWidth = $state(0)
|
||||||
let resizingInitialMouseX = $state(0);
|
let resizingInitialMouseX = $state(0)
|
||||||
let resizingPosition = $state<'left' | 'right'>('left');
|
let resizingPosition = $state<'left' | 'right'>('left')
|
||||||
|
|
||||||
function handleResizingPosition(e: MouseEvent, position: 'left' | 'right') {
|
function handleResizingPosition(e: MouseEvent, position: 'left' | 'right') {
|
||||||
startResize(e);
|
startResize(e)
|
||||||
resizingPosition = position;
|
resizingPosition = position
|
||||||
}
|
}
|
||||||
|
|
||||||
function startResize(e: MouseEvent) {
|
function startResize(e: MouseEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault()
|
||||||
resizing = true;
|
resizing = true
|
||||||
resizingInitialMouseX = e.clientX;
|
resizingInitialMouseX = e.clientX
|
||||||
if (vidRef) resizingInitialWidth = vidRef.offsetWidth;
|
if (vidRef) resizingInitialWidth = vidRef.offsetWidth
|
||||||
}
|
}
|
||||||
|
|
||||||
function resize(e: MouseEvent) {
|
function resize(e: MouseEvent) {
|
||||||
if (!resizing) return;
|
if (!resizing) return
|
||||||
let dx = e.clientX - resizingInitialMouseX;
|
let dx = e.clientX - resizingInitialMouseX
|
||||||
if (resizingPosition === 'left') {
|
if (resizingPosition === 'left') {
|
||||||
dx = resizingInitialMouseX - e.clientX;
|
dx = resizingInitialMouseX - e.clientX
|
||||||
}
|
}
|
||||||
const newWidth = Math.max(resizingInitialWidth + dx, minWidth);
|
const newWidth = Math.max(resizingInitialWidth + dx, minWidth)
|
||||||
const parentWidth = nodeRef?.parentElement?.offsetWidth || 0;
|
const parentWidth = nodeRef?.parentElement?.offsetWidth || 0
|
||||||
if (newWidth < parentWidth) {
|
if (newWidth < parentWidth) {
|
||||||
updateAttributes({ width: newWidth });
|
updateAttributes({ width: newWidth })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function endResize() {
|
function endResize() {
|
||||||
resizing = false;
|
resizing = false
|
||||||
resizingInitialMouseX = 0;
|
resizingInitialMouseX = 0
|
||||||
resizingInitialWidth = 0;
|
resizingInitialWidth = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleTouchStart(e: TouchEvent, position: 'left' | 'right') {
|
function handleTouchStart(e: TouchEvent, position: 'left' | 'right') {
|
||||||
e.preventDefault();
|
e.preventDefault()
|
||||||
resizing = true;
|
resizing = true
|
||||||
resizingPosition = position;
|
resizingPosition = position
|
||||||
resizingInitialMouseX = e.touches[0].clientX;
|
resizingInitialMouseX = e.touches[0].clientX
|
||||||
if (vidRef) resizingInitialWidth = vidRef.offsetWidth;
|
if (vidRef) resizingInitialWidth = vidRef.offsetWidth
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleTouchMove(e: TouchEvent) {
|
function handleTouchMove(e: TouchEvent) {
|
||||||
if (!resizing) return;
|
if (!resizing) return
|
||||||
let dx = e.touches[0].clientX - resizingInitialMouseX;
|
let dx = e.touches[0].clientX - resizingInitialMouseX
|
||||||
if (resizingPosition === 'left') {
|
if (resizingPosition === 'left') {
|
||||||
dx = resizingInitialMouseX - e.touches[0].clientX;
|
dx = resizingInitialMouseX - e.touches[0].clientX
|
||||||
}
|
}
|
||||||
const newWidth = Math.max(resizingInitialWidth + dx, minWidth);
|
const newWidth = Math.max(resizingInitialWidth + dx, minWidth)
|
||||||
const parentWidth = nodeRef?.parentElement?.offsetWidth || 0;
|
const parentWidth = nodeRef?.parentElement?.offsetWidth || 0
|
||||||
if (newWidth < parentWidth) {
|
if (newWidth < parentWidth) {
|
||||||
updateAttributes({ width: newWidth });
|
updateAttributes({ width: newWidth })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleTouchEnd() {
|
function handleTouchEnd() {
|
||||||
resizing = false;
|
resizing = false
|
||||||
resizingInitialMouseX = 0;
|
resizingInitialMouseX = 0
|
||||||
resizingInitialWidth = 0;
|
resizingInitialWidth = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
// Attach id to nodeRef
|
// Attach id to nodeRef
|
||||||
nodeRef = document.getElementById('resizable-container-media') as HTMLDivElement;
|
nodeRef = document.getElementById('resizable-container-media') as HTMLDivElement
|
||||||
|
|
||||||
// Mouse events
|
// Mouse events
|
||||||
window.addEventListener('mousemove', resize);
|
window.addEventListener('mousemove', resize)
|
||||||
window.addEventListener('mouseup', endResize);
|
window.addEventListener('mouseup', endResize)
|
||||||
// Touch events
|
// Touch events
|
||||||
window.addEventListener('touchmove', handleTouchMove);
|
window.addEventListener('touchmove', handleTouchMove)
|
||||||
window.addEventListener('touchend', handleTouchEnd);
|
window.addEventListener('touchend', handleTouchEnd)
|
||||||
});
|
})
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
window.removeEventListener('mousemove', resize);
|
window.removeEventListener('mousemove', resize)
|
||||||
window.removeEventListener('mouseup', endResize);
|
window.removeEventListener('mouseup', endResize)
|
||||||
window.removeEventListener('touchmove', handleTouchMove);
|
window.removeEventListener('touchmove', handleTouchMove)
|
||||||
window.removeEventListener('touchend', handleTouchEnd);
|
window.removeEventListener('touchend', handleTouchEnd)
|
||||||
});
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<NodeViewWrapper
|
<NodeViewWrapper
|
||||||
|
|
@ -136,10 +136,10 @@
|
||||||
aria-label="Resize left"
|
aria-label="Resize left"
|
||||||
class="edra-media-resize-handle edra-media-resize-handle-left"
|
class="edra-media-resize-handle edra-media-resize-handle-left"
|
||||||
onmousedown={(event: MouseEvent) => {
|
onmousedown={(event: MouseEvent) => {
|
||||||
handleResizingPosition(event, 'left');
|
handleResizingPosition(event, 'left')
|
||||||
}}
|
}}
|
||||||
ontouchstart={(event: TouchEvent) => {
|
ontouchstart={(event: TouchEvent) => {
|
||||||
handleTouchStart(event, 'left');
|
handleTouchStart(event, 'left')
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div class="edra-media-resize-indicator"></div>
|
<div class="edra-media-resize-indicator"></div>
|
||||||
|
|
@ -151,10 +151,10 @@
|
||||||
aria-label="Resize right"
|
aria-label="Resize right"
|
||||||
class="edra-media-resize-handle edra-media-resize-handle-right"
|
class="edra-media-resize-handle edra-media-resize-handle-right"
|
||||||
onmousedown={(event: MouseEvent) => {
|
onmousedown={(event: MouseEvent) => {
|
||||||
handleResizingPosition(event, 'right');
|
handleResizingPosition(event, 'right')
|
||||||
}}
|
}}
|
||||||
ontouchstart={(event: TouchEvent) => {
|
ontouchstart={(event: TouchEvent) => {
|
||||||
handleTouchStart(event, 'right');
|
handleTouchStart(event, 'right')
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div class="edra-media-resize-indicator"></div>
|
<div class="edra-media-resize-indicator"></div>
|
||||||
|
|
@ -185,7 +185,7 @@
|
||||||
<button
|
<button
|
||||||
class="edra-toolbar-button"
|
class="edra-toolbar-button"
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
if (caption === null || caption.trim() === '') caption = 'Video Caption';
|
if (caption === null || caption.trim() === '') caption = 'Video Caption'
|
||||||
}}
|
}}
|
||||||
title="Caption"
|
title="Caption"
|
||||||
>
|
>
|
||||||
|
|
@ -194,7 +194,7 @@
|
||||||
<button
|
<button
|
||||||
class="edra-toolbar-button"
|
class="edra-toolbar-button"
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
duplicateContent(editor, node);
|
duplicateContent(editor, node)
|
||||||
}}
|
}}
|
||||||
title="Duplicate"
|
title="Duplicate"
|
||||||
>
|
>
|
||||||
|
|
@ -205,7 +205,7 @@
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
updateAttributes({
|
updateAttributes({
|
||||||
width: 'fit-content'
|
width: 'fit-content'
|
||||||
});
|
})
|
||||||
}}
|
}}
|
||||||
title="Full Screen"
|
title="Full Screen"
|
||||||
>
|
>
|
||||||
|
|
@ -214,7 +214,7 @@
|
||||||
<button
|
<button
|
||||||
class="edra-toolbar-button edra-destructive"
|
class="edra-toolbar-button edra-destructive"
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
deleteNode();
|
deleteNode()
|
||||||
}}
|
}}
|
||||||
title="Delete"
|
title="Delete"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,17 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { NodeViewProps } from '@tiptap/core';
|
import type { NodeViewProps } from '@tiptap/core'
|
||||||
import Video from 'lucide-svelte/icons/video';
|
import Video from 'lucide-svelte/icons/video'
|
||||||
import { NodeViewWrapper } from 'svelte-tiptap';
|
import { NodeViewWrapper } from 'svelte-tiptap'
|
||||||
const { editor }: NodeViewProps = $props();
|
const { editor }: NodeViewProps = $props()
|
||||||
|
|
||||||
function handleClick(e: MouseEvent) {
|
function handleClick(e: MouseEvent) {
|
||||||
if (!editor.isEditable) return;
|
if (!editor.isEditable) return
|
||||||
e.preventDefault();
|
e.preventDefault()
|
||||||
const videoUrl = prompt('Enter the URL of the video:');
|
const videoUrl = prompt('Enter the URL of the video:')
|
||||||
if (!videoUrl) {
|
if (!videoUrl) {
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
editor.chain().focus().setVideo(videoUrl).run();
|
editor.chain().focus().setVideo(videoUrl).run()
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,43 +1,43 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { type Editor } from '@tiptap/core';
|
import { type Editor } from '@tiptap/core'
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte'
|
||||||
|
|
||||||
import { initiateEditor } from '../editor.js';
|
import { initiateEditor } from '../editor.js'
|
||||||
import './style.css';
|
import './style.css'
|
||||||
import 'katex/dist/katex.min.css';
|
import 'katex/dist/katex.min.css'
|
||||||
|
|
||||||
// Lowlight
|
// Lowlight
|
||||||
import { CodeBlockLowlight } from '@tiptap/extension-code-block-lowlight';
|
import { CodeBlockLowlight } from '@tiptap/extension-code-block-lowlight'
|
||||||
import { all, createLowlight } from 'lowlight';
|
import { all, createLowlight } from 'lowlight'
|
||||||
import '../editor.css';
|
import '../editor.css'
|
||||||
import '../onedark.css';
|
import '../onedark.css'
|
||||||
import { SvelteNodeViewRenderer } from 'svelte-tiptap';
|
import { SvelteNodeViewRenderer } from 'svelte-tiptap'
|
||||||
import CodeExtended from './components/CodeExtended.svelte';
|
import CodeExtended from './components/CodeExtended.svelte'
|
||||||
import { AudioPlaceholder } from '../extensions/audio/AudioPlaceholder.js';
|
import { AudioPlaceholder } from '../extensions/audio/AudioPlaceholder.js'
|
||||||
import AudioPlaceholderComponent from './components/AudioPlaceholder.svelte';
|
import AudioPlaceholderComponent from './components/AudioPlaceholder.svelte'
|
||||||
import AudioExtendedComponent from './components/AudioExtended.svelte';
|
import AudioExtendedComponent from './components/AudioExtended.svelte'
|
||||||
import { ImagePlaceholder } from '../extensions/image/ImagePlaceholder.js';
|
import { ImagePlaceholder } from '../extensions/image/ImagePlaceholder.js'
|
||||||
import ImagePlaceholderComponent from './components/ImagePlaceholder.svelte';
|
import ImagePlaceholderComponent from './components/ImagePlaceholder.svelte'
|
||||||
import { VideoPlaceholder } from '../extensions/video/VideoPlaceholder.js';
|
import { VideoPlaceholder } from '../extensions/video/VideoPlaceholder.js'
|
||||||
import VideoPlaceholderComponent from './components/VideoPlaceholder.svelte';
|
import VideoPlaceholderComponent from './components/VideoPlaceholder.svelte'
|
||||||
import { ImageExtended } from '../extensions/image/ImageExtended.js';
|
import { ImageExtended } from '../extensions/image/ImageExtended.js'
|
||||||
import ImageExtendedComponent from './components/ImageExtended.svelte';
|
import ImageExtendedComponent from './components/ImageExtended.svelte'
|
||||||
import VideoExtendedComponent from './components/VideoExtended.svelte';
|
import VideoExtendedComponent from './components/VideoExtended.svelte'
|
||||||
import { VideoExtended } from '../extensions/video/VideoExtended.js';
|
import { VideoExtended } from '../extensions/video/VideoExtended.js'
|
||||||
import { AudioExtended } from '../extensions/audio/AudiExtended.js';
|
import { AudioExtended } from '../extensions/audio/AudiExtended.js'
|
||||||
import LinkMenu from './menus/link-menu.svelte';
|
import LinkMenu from './menus/link-menu.svelte'
|
||||||
import TableRowMenu from './menus/table/table-row-menu.svelte';
|
import TableRowMenu from './menus/table/table-row-menu.svelte'
|
||||||
import TableColMenu from './menus/table/table-col-menu.svelte';
|
import TableColMenu from './menus/table/table-col-menu.svelte'
|
||||||
import slashcommand from '../extensions/slash-command/slashcommand.js';
|
import slashcommand from '../extensions/slash-command/slashcommand.js'
|
||||||
import SlashCommandList from './components/SlashCommandList.svelte';
|
import SlashCommandList from './components/SlashCommandList.svelte'
|
||||||
import LoaderCircle from 'lucide-svelte/icons/loader-circle';
|
import LoaderCircle from 'lucide-svelte/icons/loader-circle'
|
||||||
import { focusEditor, type EdraProps } from '../utils.js';
|
import { focusEditor, type EdraProps } from '../utils.js'
|
||||||
import IFramePlaceholderComponent from './components/IFramePlaceholder.svelte';
|
import IFramePlaceholderComponent from './components/IFramePlaceholder.svelte'
|
||||||
import { IFramePlaceholder } from '../extensions/iframe/IFramePlaceholder.js';
|
import { IFramePlaceholder } from '../extensions/iframe/IFramePlaceholder.js'
|
||||||
import { IFrameExtended } from '../extensions/iframe/IFrameExtended.js';
|
import { IFrameExtended } from '../extensions/iframe/IFrameExtended.js'
|
||||||
import IFrameExtendedComponent from './components/IFrameExtended.svelte';
|
import IFrameExtendedComponent from './components/IFrameExtended.svelte'
|
||||||
|
|
||||||
const lowlight = createLowlight(all);
|
const lowlight = createLowlight(all)
|
||||||
|
|
||||||
let {
|
let {
|
||||||
class: className = '',
|
class: className = '',
|
||||||
|
|
@ -50,9 +50,9 @@
|
||||||
showTableBubbleMenu = true,
|
showTableBubbleMenu = true,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
children
|
children
|
||||||
}: EdraProps = $props();
|
}: EdraProps = $props()
|
||||||
|
|
||||||
let element = $state<HTMLElement>();
|
let element = $state<HTMLElement>()
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
editor = initiateEditor(
|
editor = initiateEditor(
|
||||||
|
|
@ -64,7 +64,7 @@
|
||||||
lowlight
|
lowlight
|
||||||
}).extend({
|
}).extend({
|
||||||
addNodeView() {
|
addNodeView() {
|
||||||
return SvelteNodeViewRenderer(CodeExtended);
|
return SvelteNodeViewRenderer(CodeExtended)
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
AudioPlaceholder(AudioPlaceholderComponent),
|
AudioPlaceholder(AudioPlaceholderComponent),
|
||||||
|
|
@ -81,13 +81,13 @@
|
||||||
editable,
|
editable,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
onTransaction: (props) => {
|
onTransaction: (props) => {
|
||||||
editor = undefined;
|
editor = undefined
|
||||||
editor = props.editor;
|
editor = props.editor
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
)
|
||||||
return () => editor?.destroy();
|
return () => editor?.destroy()
|
||||||
});
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class={`edra ${className}`}>
|
<div class={`edra ${className}`}>
|
||||||
|
|
@ -113,7 +113,7 @@
|
||||||
onclick={(event) => focusEditor(editor, event)}
|
onclick={(event) => focusEditor(editor, event)}
|
||||||
onkeydown={(event) => {
|
onkeydown={(event) => {
|
||||||
if (event.key === 'Enter' || event.key === ' ') {
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
focusEditor(editor, event);
|
focusEditor(editor, event)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
class="edra-editor"
|
class="edra-editor"
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
export { default as Edra } from './editor.svelte';
|
export { default as Edra } from './editor.svelte'
|
||||||
export { default as EdraToolbar } from './toolbar.svelte';
|
export { default as EdraToolbar } from './toolbar.svelte'
|
||||||
export { default as EdraBubbleMenu } from './menus/bubble-menu.svelte';
|
export { default as EdraBubbleMenu } from './menus/bubble-menu.svelte'
|
||||||
|
|
|
||||||
|
|
@ -1,89 +1,89 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { BubbleMenu } from 'svelte-tiptap';
|
import { BubbleMenu } from 'svelte-tiptap'
|
||||||
import { isTextSelection, type Editor } from '@tiptap/core';
|
import { isTextSelection, type Editor } from '@tiptap/core'
|
||||||
import { commands } from '../../commands/commands.js';
|
import { commands } from '../../commands/commands.js'
|
||||||
import EdraToolBarIcon from '../components/EdraToolBarIcon.svelte';
|
import EdraToolBarIcon from '../components/EdraToolBarIcon.svelte'
|
||||||
import type { ShouldShowProps } from '../../utils.js';
|
import type { ShouldShowProps } from '../../utils.js'
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
class?: string;
|
class?: string
|
||||||
editor: Editor;
|
editor: Editor
|
||||||
children?: Snippet<[]>;
|
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', () => {
|
editor.view.dom.addEventListener('dragstart', () => {
|
||||||
isDragging = true;
|
isDragging = true
|
||||||
});
|
})
|
||||||
|
|
||||||
editor.view.dom.addEventListener('drop', () => {
|
editor.view.dom.addEventListener('drop', () => {
|
||||||
isDragging = true;
|
isDragging = true
|
||||||
|
|
||||||
// Allow some time for the drop action to complete before re-enabling
|
// Allow some time for the drop action to complete before re-enabling
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
isDragging = false;
|
isDragging = false
|
||||||
}, 100); // Adjust delay if needed
|
}, 100) // Adjust delay if needed
|
||||||
});
|
})
|
||||||
|
|
||||||
const bubbleMenuCommands = [
|
const bubbleMenuCommands = [
|
||||||
...commands['text-formatting'].commands,
|
...commands['text-formatting'].commands,
|
||||||
...commands.alignment.commands,
|
...commands.alignment.commands,
|
||||||
...commands.lists.commands
|
...commands.lists.commands
|
||||||
];
|
]
|
||||||
|
|
||||||
const colorCommands = commands.colors.commands;
|
const colorCommands = commands.colors.commands
|
||||||
const fontCommands = commands.fonts.commands;
|
const fontCommands = commands.fonts.commands
|
||||||
|
|
||||||
function shouldShow(props: ShouldShowProps) {
|
function shouldShow(props: ShouldShowProps) {
|
||||||
if (!props.editor.isEditable) return false;
|
if (!props.editor.isEditable) return false
|
||||||
const { view, editor } = props;
|
const { view, editor } = props
|
||||||
if (!view || editor.view.dragging) {
|
if (!view || editor.view.dragging) {
|
||||||
return false;
|
return false
|
||||||
}
|
}
|
||||||
if (editor.isActive('link')) return false;
|
if (editor.isActive('link')) return false
|
||||||
if (editor.isActive('codeBlock')) return false;
|
if (editor.isActive('codeBlock')) return false
|
||||||
const {
|
const {
|
||||||
state: {
|
state: {
|
||||||
doc,
|
doc,
|
||||||
selection,
|
selection,
|
||||||
selection: { empty, from, to }
|
selection: { empty, from, to }
|
||||||
}
|
}
|
||||||
} = editor;
|
} = editor
|
||||||
// check if the selection is a table grip
|
// check if the selection is a table grip
|
||||||
const domAtPos = view.domAtPos(from || 0).node as HTMLElement;
|
const domAtPos = view.domAtPos(from || 0).node as HTMLElement
|
||||||
const nodeDOM = view.nodeDOM(from || 0) as HTMLElement;
|
const nodeDOM = view.nodeDOM(from || 0) as HTMLElement
|
||||||
const node = nodeDOM || domAtPos;
|
const node = nodeDOM || domAtPos
|
||||||
|
|
||||||
if (isTableGripSelected(node)) {
|
if (isTableGripSelected(node)) {
|
||||||
return false;
|
return false
|
||||||
}
|
}
|
||||||
// Sometime check for `empty` is not enough.
|
// Sometime check for `empty` is not enough.
|
||||||
// Doubleclick an empty paragraph returns a node size of 2.
|
// Doubleclick an empty paragraph returns a node size of 2.
|
||||||
// So we check also for an empty text size.
|
// 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) {
|
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) => {
|
const isTableGripSelected = (node: HTMLElement) => {
|
||||||
let container = node;
|
let container = node
|
||||||
while (container && !['TD', 'TH'].includes(container.tagName)) {
|
while (container && !['TD', 'TH'].includes(container.tagName)) {
|
||||||
container = container.parentElement!;
|
container = container.parentElement!
|
||||||
}
|
}
|
||||||
const gripColumn =
|
const gripColumn =
|
||||||
container && container.querySelector && container.querySelector('a.grip-column.selected');
|
container && container.querySelector && container.querySelector('a.grip-column.selected')
|
||||||
const gripRow =
|
const gripRow =
|
||||||
container && container.querySelector && container.querySelector('a.grip-row.selected');
|
container && container.querySelector && container.querySelector('a.grip-row.selected')
|
||||||
if (gripColumn || gripRow) {
|
if (gripColumn || gripRow) {
|
||||||
return true;
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
return false;
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<BubbleMenu
|
<BubbleMenu
|
||||||
|
|
@ -130,14 +130,14 @@
|
||||||
{editor}
|
{editor}
|
||||||
style={`color: ${editor.getAttributes('textStyle').color};`}
|
style={`color: ${editor.getAttributes('textStyle').color};`}
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
const color = editor.getAttributes('textStyle').color;
|
const color = editor.getAttributes('textStyle').color
|
||||||
const hasColor = editor.isActive('textStyle', { color });
|
const hasColor = editor.isActive('textStyle', { color })
|
||||||
if (hasColor) {
|
if (hasColor) {
|
||||||
editor.chain().focus().unsetColor().run();
|
editor.chain().focus().unsetColor().run()
|
||||||
} else {
|
} else {
|
||||||
const color = prompt('Enter the color of the text:');
|
const color = prompt('Enter the color of the text:')
|
||||||
if (color !== null) {
|
if (color !== null) {
|
||||||
editor.chain().focus().setColor(color).run();
|
editor.chain().focus().setColor(color).run()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|
@ -147,13 +147,13 @@
|
||||||
{editor}
|
{editor}
|
||||||
style={`background-color: ${editor.getAttributes('highlight').color};`}
|
style={`background-color: ${editor.getAttributes('highlight').color};`}
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
const hasHightlight = editor.isActive('highlight');
|
const hasHightlight = editor.isActive('highlight')
|
||||||
if (hasHightlight) {
|
if (hasHightlight) {
|
||||||
editor.chain().focus().unsetHighlight().run();
|
editor.chain().focus().unsetHighlight().run()
|
||||||
} else {
|
} else {
|
||||||
const color = prompt('Enter the color of the highlight:');
|
const color = prompt('Enter the color of the highlight:')
|
||||||
if (color !== null) {
|
if (color !== null) {
|
||||||
editor.chain().focus().setHighlight({ color }).run();
|
editor.chain().focus().setHighlight({ color }).run()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,36 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { type Editor } from '@tiptap/core';
|
import { type Editor } from '@tiptap/core'
|
||||||
import { BubbleMenu } from 'svelte-tiptap';
|
import { BubbleMenu } from 'svelte-tiptap'
|
||||||
import type { ShouldShowProps } from '../../utils.js';
|
import type { ShouldShowProps } from '../../utils.js'
|
||||||
import Copy from 'lucide-svelte/icons/copy';
|
import Copy from 'lucide-svelte/icons/copy'
|
||||||
import Trash from 'lucide-svelte/icons/trash';
|
import Trash from 'lucide-svelte/icons/trash'
|
||||||
import Edit from 'lucide-svelte/icons/pen';
|
import Edit from 'lucide-svelte/icons/pen'
|
||||||
import Check from 'lucide-svelte/icons/check';
|
import Check from 'lucide-svelte/icons/check'
|
||||||
|
|
||||||
interface Props {
|
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) {
|
function setLink(url: string) {
|
||||||
if (url.trim() === '') {
|
if (url.trim() === '') {
|
||||||
editor.chain().focus().extendMarkRange('link').unsetLink().run();
|
editor.chain().focus().extendMarkRange('link').unsetLink().run()
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
|
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run()
|
||||||
}
|
}
|
||||||
|
|
||||||
let linkInput = $state('');
|
let linkInput = $state('')
|
||||||
let isLinkValid = $state(true);
|
let isLinkValid = $state(true)
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
isLinkValid = validateURL(linkInput);
|
isLinkValid = validateURL(linkInput)
|
||||||
});
|
})
|
||||||
|
|
||||||
function validateURL(url: string): boolean {
|
function validateURL(url: string): boolean {
|
||||||
const urlPattern = new RegExp(
|
const urlPattern = new RegExp(
|
||||||
|
|
@ -41,8 +41,8 @@
|
||||||
'(\\?[;&a-zA-Z\\d%_.~+=-]*)?' + // query string
|
'(\\?[;&a-zA-Z\\d%_.~+=-]*)?' + // query string
|
||||||
'(\\#[-a-zA-Z\\d_]*)?$', // fragment locator
|
'(\\#[-a-zA-Z\\d_]*)?$', // fragment locator
|
||||||
'i'
|
'i'
|
||||||
);
|
)
|
||||||
return urlPattern.test(url);
|
return urlPattern.test(url)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -50,14 +50,14 @@
|
||||||
{editor}
|
{editor}
|
||||||
pluginKey="link-menu"
|
pluginKey="link-menu"
|
||||||
shouldShow={(props: ShouldShowProps) => {
|
shouldShow={(props: ShouldShowProps) => {
|
||||||
if (!props.editor.isEditable) return false;
|
if (!props.editor.isEditable) return false
|
||||||
if (props.editor.isActive('link')) {
|
if (props.editor.isActive('link')) {
|
||||||
return true;
|
return true
|
||||||
} else {
|
} else {
|
||||||
isEditing = false;
|
isEditing = false
|
||||||
linkInput = '';
|
linkInput = ''
|
||||||
isLinkValid = true;
|
isLinkValid = true
|
||||||
return false;
|
return false
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
class="bubble-menu-wrapper"
|
class="bubble-menu-wrapper"
|
||||||
|
|
@ -79,8 +79,8 @@
|
||||||
<button
|
<button
|
||||||
class="edra-command-button"
|
class="edra-command-button"
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
linkInput = link;
|
linkInput = link
|
||||||
isEditing = true;
|
isEditing = true
|
||||||
}}
|
}}
|
||||||
title="Edit the URL"
|
title="Edit the URL"
|
||||||
>
|
>
|
||||||
|
|
@ -89,7 +89,7 @@
|
||||||
<button
|
<button
|
||||||
class="edra-command-button"
|
class="edra-command-button"
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
navigator.clipboard.writeText(link);
|
navigator.clipboard.writeText(link)
|
||||||
}}
|
}}
|
||||||
title="Copy the URL to the clipboard"
|
title="Copy the URL to the clipboard"
|
||||||
>
|
>
|
||||||
|
|
@ -98,7 +98,7 @@
|
||||||
<button
|
<button
|
||||||
class="edra-command-button"
|
class="edra-command-button"
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
editor.chain().focus().extendMarkRange('link').unsetLink().run();
|
editor.chain().focus().extendMarkRange('link').unsetLink().run()
|
||||||
}}
|
}}
|
||||||
title="Remove the link"
|
title="Remove the link"
|
||||||
>
|
>
|
||||||
|
|
@ -108,9 +108,9 @@
|
||||||
<button
|
<button
|
||||||
class="edra-command-button"
|
class="edra-command-button"
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
isEditing = false;
|
isEditing = false
|
||||||
editor.commands.focus();
|
editor.commands.focus()
|
||||||
setLink(linkInput);
|
setLink(linkInput)
|
||||||
}}
|
}}
|
||||||
disabled={!isLinkValid}
|
disabled={!isLinkValid}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,32 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { ShouldShowProps } from '../../../utils.js';
|
import type { ShouldShowProps } from '../../../utils.js'
|
||||||
import { type Editor } from '@tiptap/core';
|
import { type Editor } from '@tiptap/core'
|
||||||
import { BubbleMenu } from 'svelte-tiptap';
|
import { BubbleMenu } from 'svelte-tiptap'
|
||||||
import ArrowLeftFromLine from 'lucide-svelte/icons/arrow-left-from-line';
|
import ArrowLeftFromLine from 'lucide-svelte/icons/arrow-left-from-line'
|
||||||
import ArrowRightFromLine from 'lucide-svelte/icons/arrow-right-from-line';
|
import ArrowRightFromLine from 'lucide-svelte/icons/arrow-right-from-line'
|
||||||
import Trash from 'lucide-svelte/icons/trash';
|
import Trash from 'lucide-svelte/icons/trash'
|
||||||
import { isColumnGripSelected } from '../../../extensions/table/utils.js';
|
import { isColumnGripSelected } from '../../../extensions/table/utils.js'
|
||||||
interface Props {
|
interface Props {
|
||||||
editor: Editor;
|
editor: Editor
|
||||||
}
|
}
|
||||||
|
|
||||||
let { editor }: Props = $props();
|
let { editor }: Props = $props()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<BubbleMenu
|
<BubbleMenu
|
||||||
{editor}
|
{editor}
|
||||||
pluginKey="table-col-menu"
|
pluginKey="table-col-menu"
|
||||||
shouldShow={(props: ShouldShowProps) => {
|
shouldShow={(props: ShouldShowProps) => {
|
||||||
if (!props.editor.isEditable) return false;
|
if (!props.editor.isEditable) return false
|
||||||
if (!props.state) {
|
if (!props.state) {
|
||||||
return false;
|
return false
|
||||||
}
|
}
|
||||||
return isColumnGripSelected({
|
return isColumnGripSelected({
|
||||||
editor: props.editor,
|
editor: props.editor,
|
||||||
view: props.view,
|
view: props.view,
|
||||||
state: props.state,
|
state: props.state,
|
||||||
from: props.from
|
from: props.from
|
||||||
});
|
})
|
||||||
}}
|
}}
|
||||||
class="edra-menu-wrapper"
|
class="edra-menu-wrapper"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,32 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { ShouldShowProps } from '../../../utils.js';
|
import type { ShouldShowProps } from '../../../utils.js'
|
||||||
import { type Editor } from '@tiptap/core';
|
import { type Editor } from '@tiptap/core'
|
||||||
import { BubbleMenu } from 'svelte-tiptap';
|
import { BubbleMenu } from 'svelte-tiptap'
|
||||||
import ArrowDownFromLine from 'lucide-svelte/icons/arrow-down-from-line';
|
import ArrowDownFromLine from 'lucide-svelte/icons/arrow-down-from-line'
|
||||||
import ArrowUpFromLine from 'lucide-svelte/icons/arrow-up-from-line';
|
import ArrowUpFromLine from 'lucide-svelte/icons/arrow-up-from-line'
|
||||||
import Trash from 'lucide-svelte/icons/trash';
|
import Trash from 'lucide-svelte/icons/trash'
|
||||||
import { isRowGripSelected } from '../../../extensions/table/utils.js';
|
import { isRowGripSelected } from '../../../extensions/table/utils.js'
|
||||||
interface Props {
|
interface Props {
|
||||||
editor: Editor;
|
editor: Editor
|
||||||
}
|
}
|
||||||
|
|
||||||
let { editor }: Props = $props();
|
let { editor }: Props = $props()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<BubbleMenu
|
<BubbleMenu
|
||||||
{editor}
|
{editor}
|
||||||
pluginKey="table-row-menu"
|
pluginKey="table-row-menu"
|
||||||
shouldShow={(props: ShouldShowProps) => {
|
shouldShow={(props: ShouldShowProps) => {
|
||||||
if (!props.editor.isEditable) return false;
|
if (!props.editor.isEditable) return false
|
||||||
if (!props.state) {
|
if (!props.state) {
|
||||||
return false;
|
return false
|
||||||
}
|
}
|
||||||
return isRowGripSelected({
|
return isRowGripSelected({
|
||||||
editor: props.editor,
|
editor: props.editor,
|
||||||
view: props.view,
|
view: props.view,
|
||||||
state: props.state,
|
state: props.state,
|
||||||
from: props.from
|
from: props.from
|
||||||
});
|
})
|
||||||
}}
|
}}
|
||||||
class="edra-menu-wrapper"
|
class="edra-menu-wrapper"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,23 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Editor } from '@tiptap/core';
|
import type { Editor } from '@tiptap/core'
|
||||||
import { commands } from '../commands/commands.js';
|
import { commands } from '../commands/commands.js'
|
||||||
import EdraToolBarIcon from './components/EdraToolBarIcon.svelte';
|
import EdraToolBarIcon from './components/EdraToolBarIcon.svelte'
|
||||||
import SearchAndReplace from './components/SearchAndReplace.svelte';
|
import SearchAndReplace from './components/SearchAndReplace.svelte'
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
class?: string;
|
class?: string
|
||||||
editor: Editor;
|
editor: Editor
|
||||||
children?: Snippet<[]>;
|
children?: Snippet<[]>
|
||||||
}
|
}
|
||||||
|
|
||||||
const { class: className = '', editor, children }: Props = $props();
|
const { class: className = '', editor, children }: Props = $props()
|
||||||
|
|
||||||
// Special components that are handled separately
|
// Special components that are handled separately
|
||||||
let showSearchAndReplace = $state(false);
|
let showSearchAndReplace = $state(false)
|
||||||
const colorCommands = commands.colors.commands;
|
const colorCommands = commands.colors.commands
|
||||||
const fontCommands = commands.fonts.commands;
|
const fontCommands = commands.fonts.commands
|
||||||
const excludedCommands = ['colors', 'fonts'];
|
const excludedCommands = ['colors', 'fonts']
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class={`edra-toolbar ${className}`}>
|
<div class={`edra-toolbar ${className}`}>
|
||||||
|
|
@ -44,14 +44,14 @@
|
||||||
{editor}
|
{editor}
|
||||||
style={`color: ${editor.getAttributes('textStyle').color};`}
|
style={`color: ${editor.getAttributes('textStyle').color};`}
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
const color = editor.getAttributes('textStyle').color;
|
const color = editor.getAttributes('textStyle').color
|
||||||
const hasColor = editor.isActive('textStyle', { color });
|
const hasColor = editor.isActive('textStyle', { color })
|
||||||
if (hasColor) {
|
if (hasColor) {
|
||||||
editor.chain().focus().unsetColor().run();
|
editor.chain().focus().unsetColor().run()
|
||||||
} else {
|
} else {
|
||||||
const color = prompt('Enter the color of the text:');
|
const color = prompt('Enter the color of the text:')
|
||||||
if (color !== null) {
|
if (color !== null) {
|
||||||
editor.chain().focus().setColor(color).run();
|
editor.chain().focus().setColor(color).run()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|
@ -61,13 +61,13 @@
|
||||||
{editor}
|
{editor}
|
||||||
style={`background-color: ${editor.getAttributes('highlight').color};`}
|
style={`background-color: ${editor.getAttributes('highlight').color};`}
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
const hasHightlight = editor.isActive('highlight');
|
const hasHightlight = editor.isActive('highlight')
|
||||||
if (hasHightlight) {
|
if (hasHightlight) {
|
||||||
editor.chain().focus().unsetHighlight().run();
|
editor.chain().focus().unsetHighlight().run()
|
||||||
} else {
|
} else {
|
||||||
const color = prompt('Enter the color of the highlight:');
|
const color = prompt('Enter the color of the highlight:')
|
||||||
if (color !== null) {
|
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