tbh i dont know whats going on anymore

This commit is contained in:
Justin Edmund 2025-05-31 02:08:26 -07:00
parent baa030ac1c
commit c7b4f57ab0
164 changed files with 22363 additions and 11428 deletions

3
.gitignore vendored
View file

@ -24,3 +24,6 @@ vite.config.ts.timestamp-*
# Local uploads (for development)
/static/local-uploads
*storybook.log
storybook-static

46
.storybook/main.ts Normal file
View 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
View 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;

View file

@ -34,6 +34,11 @@ npm run preview
This is a SvelteKit personal portfolio site for @jedmund that integrates with multiple external APIs to display real-time data about music listening habits and gaming activity.
We are using Svelte 5 in Runes mode, so make sure to only write solutions that will work with that newer syntax.
Make sure to use the CSS variables that are defined across the various files in `src/assets/styles`. When making new colors or defining new variables, check that it doesn't exist first, then define it.
### Key Architecture Components
**API Integration Layer** (`src/routes/api/`)

View file

@ -1,3 +1,6 @@
// For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format
import storybook from "eslint-plugin-storybook";
import js from '@eslint/js'
import ts from 'typescript-eslint'
import svelte from 'eslint-plugin-svelte'
@ -29,5 +32,6 @@ export default [
},
{
ignores: ['build/', '.svelte-kit/', 'dist/']
}
]
},
...storybook.configs["flat/recommended"]
];

1272
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -14,11 +14,17 @@
"db:migrate": "prisma migrate dev",
"db:seed": "prisma db seed",
"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": {
"@musicorum/lastfm": "github:jedmund/lastfm",
"@poppanator/sveltekit-svg": "^5.0.0-svelte5.4",
"@storybook/addon-a11y": "^9.0.1",
"@storybook/addon-docs": "^9.0.1",
"@storybook/addon-svelte-csf": "^5.0.3",
"@storybook/sveltekit": "^9.0.1",
"@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^4.0.0-next.6",
@ -27,12 +33,14 @@
"autoprefixer": "^10.4.19",
"eslint": "^9.0.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-storybook": "^9.0.1",
"eslint-plugin-svelte": "^2.36.0",
"globals": "^15.0.0",
"postcss": "^8.4.39",
"prettier": "^3.1.1",
"prettier-plugin-svelte": "^3.1.2",
"sass": "^1.77.8",
"storybook": "^9.0.1",
"svelte": "^5.0.0-next.1",
"svelte-check": "^3.6.0",
"tslib": "^2.4.1",

View file

@ -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;

View file

@ -126,12 +126,16 @@ model Photo {
model Media {
id Int @id @default(autoincrement())
filename String @db.VarChar(255)
originalName String? @db.VarChar(255) // Original filename from user (optional for backward compatibility)
mimeType String @db.VarChar(100)
size Int
url String @db.Text
thumbnailUrl String? @db.Text
width Int?
height Int?
altText String? @db.Text // Alt text for accessibility
description String? @db.Text // Optional description
usedIn Json @default("[]") // Track where media is used
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

View 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

View 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

View file

@ -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;
}

View file

@ -69,7 +69,9 @@ $grey-95: #f5f5f5;
$grey-90: #f7f7f7;
$grey-85: #ebebeb;
$grey-80: #e8e8e8;
$grey-70: #dfdfdf;
$grey-60: #cccccc;
$grey-5: #f9f9f9;
$grey-50: #b2b2b2;
$grey-40: #999999;
$grey-30: #808080;
@ -79,9 +81,26 @@ $grey-00: #333333;
$red-80: #ff6a54;
$red-60: #e33d3d;
$red-50: #d33;
$red-40: #d31919;
$red-00: #3d0c0c;
$blue-60: #2e8bc0;
$blue-50: #1482c1;
$blue-40: #126fa8;
$blue-20: #0f5d8f;
$blue-10: #e6f3ff;
$yellow-90: #fff9e6;
$yellow-80: #ffeb99;
$yellow-70: #ffdd4d;
$yellow-60: #ffcc00;
$yellow-50: #f5c500;
$yellow-40: #e6b800;
$yellow-30: #cc9900;
$yellow-20: #996600;
$yellow-10: #664400;
$salmon-pink: #ffbdb3; // Desaturated salmon pink for hover states
$bg-color: #e8e8e8;

45
src/lib/admin-auth.ts Normal file
View 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
})
}

View file

@ -1,37 +1,35 @@
<script>
// What if we have a headphones avatar that is head bopping if the last scrobble was < 5 mins ago
// We can do a thought bubble-y thing with the album art that takes you to the album section of the page
import { onMount } from 'svelte'
import { onMount, onDestroy } from 'svelte'
import { spring } from 'svelte/motion'
let isHovering = $state(false)
let isBlinking = $state(false)
let isHovering = false
let isBlinking = false
const scale = spring(1, {
stiffness: 0.1,
damping: 0.125
})
$effect(() => {
if (isHovering) {
function handleMouseEnter() {
isHovering = true
scale.set(1.25)
} else {
}
function handleMouseLeave() {
isHovering = false
scale.set(1)
}
})
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function setBlinkState(state) {
isBlinking = state
}
async function singleBlink(duration) {
setBlinkState(true)
isBlinking = true
await sleep(duration)
setBlinkState(false)
isBlinking = false
}
async function doubleBlink() {
@ -48,25 +46,27 @@
}
}
function startBlinking() {
const blinkInterval = setInterval(() => {
let blinkInterval
onMount(() => {
blinkInterval = setInterval(() => {
if (!isHovering) {
blink()
}
}, 4000)
return () => clearInterval(blinkInterval)
return () => {
if (blinkInterval) {
clearInterval(blinkInterval)
}
}
onMount(() => {
return startBlinking()
})
</script>
<div
class="face-container"
on:mouseenter={() => (isHovering = true)}
on:mouseleave={() => (isHovering = false)}
onmouseenter={handleMouseEnter}
onmouseleave={handleMouseLeave}
style="transform: scale({$scale})"
>
<svg

View 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>

View file

@ -109,7 +109,6 @@
width: 100%;
text-align: left;
cursor: pointer;
font-family: 'cstd', 'Helvetica Neue', Helvetica, Arial, sans-serif;
transition: border-color 0.2s ease;
&:hover {
@ -163,7 +162,6 @@
gap: $unit;
font-size: 0.875rem;
color: $grey-40;
font-family: 'cstd', 'Helvetica Neue', Helvetica, Arial, sans-serif;
}
.favicon {
@ -184,7 +182,6 @@
font-weight: 600;
color: $grey-00;
line-height: 1.3;
font-family: 'cstd', 'Helvetica Neue', Helvetica, Arial, sans-serif;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
@ -196,7 +193,6 @@
font-size: 0.875rem;
color: $grey-40;
line-height: 1.4;
font-family: 'cstd', 'Helvetica Neue', Helvetica, Arial, sans-serif;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;

View file

@ -32,7 +32,6 @@
color: $grey-20; // #666
font-size: 1rem;
font-weight: 400;
font-family: 'cstd', 'Helvetica Neue', Helvetica, Arial, sans-serif;
transition: all 0.2s ease;
:global(svg) {

View file

@ -12,7 +12,15 @@
index?: number
}
let { logoUrl, backgroundColor, name, slug, description, highlightColor, index = 0 }: Props = $props()
let {
logoUrl,
backgroundColor,
name,
slug,
description,
highlightColor,
index = 0
}: Props = $props()
const isEven = $derived(index % 2 === 0)
@ -161,11 +169,7 @@
>
<div class="project-logo" style="background-color: {backgroundColor}">
{#if svgContent}
<div
bind:this={logoElement}
class="logo-svg"
style="transform: {logoTransform}"
>
<div bind:this={logoElement} class="logo-svg" style="transform: {logoTransform}">
{@html svgContent}
</div>
{:else if logoUrl}

View file

@ -91,6 +91,5 @@
padding: $unit-3x;
text-align: center;
color: $grey-40;
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
}
</style>

View file

@ -162,7 +162,6 @@
text-decoration: none;
font-size: 1rem;
font-weight: 400;
font-family: 'cstd', 'Helvetica Neue', Helvetica, Arial, sans-serif;
position: relative;
z-index: 2;
transition:

View 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>

View file

@ -1,20 +1,24 @@
<script lang="ts">
import { page } from '$app/stores'
import Avatar from '$lib/components/Avatar.svelte'
import AvatarSimple from '$lib/components/AvatarSimple.svelte'
import DashboardIcon from '$icons/dashboard.svg?component'
import WorkIcon from '$icons/work.svg?component'
import UniverseIcon from '$icons/universe.svg?component'
import PhotosIcon from '$icons/photos.svg?component'
const currentPath = $derived($page.url.pathname)
interface NavItem {
text: string
href: string
icon: string
icon: any
}
const navItems: NavItem[] = [
{ text: 'Dashboard', href: '/admin', icon: 'dashboard' },
{ text: 'Projects', href: '/admin/projects', icon: 'work' },
{ text: 'Universe', href: '/admin/posts', icon: 'universe' },
{ text: 'Media', href: '/admin/media', icon: 'photos' }
{ text: 'Dashboard', href: '/admin', icon: DashboardIcon },
{ text: 'Projects', href: '/admin/projects', icon: WorkIcon },
{ text: 'Universe', href: '/admin/posts', icon: UniverseIcon },
{ text: 'Media', href: '/admin/media', icon: PhotosIcon }
]
// Calculate active index based on current path
@ -36,7 +40,7 @@
<div class="nav-content">
<a href="/" class="nav-brand">
<div class="brand-logo">
<Avatar />
<AvatarSimple />
</div>
<span class="brand-text">Back to jedmund.com</span>
</a>
@ -44,108 +48,7 @@
<div class="nav-links">
{#each navItems as item, index}
<a href={item.href} class="nav-link" class:active={index === activeIndex}>
<svg class="nav-icon" width="20" height="20" viewBox="0 0 20 20">
{#if item.icon === 'dashboard'}
<rect
x="3"
y="3"
width="6"
height="6"
stroke="currentColor"
stroke-width="1.5"
fill="none"
rx="1"
/>
<rect
x="11"
y="3"
width="6"
height="6"
stroke="currentColor"
stroke-width="1.5"
fill="none"
rx="1"
/>
<rect
x="3"
y="11"
width="6"
height="6"
stroke="currentColor"
stroke-width="1.5"
fill="none"
rx="1"
/>
<rect
x="11"
y="11"
width="6"
height="6"
stroke="currentColor"
stroke-width="1.5"
fill="none"
rx="1"
/>
{:else if item.icon === 'work'}
<rect
x="2"
y="4"
width="16"
height="12"
stroke="currentColor"
stroke-width="1.5"
fill="none"
rx="2"
/>
<path
d="M8 4V3C8 2.44772 8.44772 2 9 2H11C11.5523 2 12 2.44772 12 3V4"
stroke="currentColor"
stroke-width="1.5"
/>
<line x1="2" y1="9" x2="18" y2="9" stroke="currentColor" stroke-width="1.5" />
{:else if item.icon === 'universe'}
<circle
cx="10"
cy="10"
r="8"
stroke="currentColor"
stroke-width="1.5"
fill="none"
/>
<circle cx="10" cy="10" r="2" fill="currentColor" />
<circle cx="10" cy="4" r="1" fill="currentColor" />
<circle cx="16" cy="10" r="1" fill="currentColor" />
<circle cx="10" cy="16" r="1" fill="currentColor" />
<circle cx="4" cy="10" r="1" fill="currentColor" />
{:else if item.icon === 'photos'}
<rect
x="3"
y="5"
width="14"
height="10"
stroke="currentColor"
stroke-width="1.5"
fill="none"
rx="1"
/>
<circle
cx="7"
cy="9"
r="1.5"
stroke="currentColor"
stroke-width="1.5"
fill="none"
/>
<path
d="M3 12L7 8L10 11L13 8L17 12"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
fill="none"
/>
{/if}
</svg>
<item.icon class="nav-icon" />
<span class="nav-text">{item.text}</span>
</a>
{/each}
@ -168,8 +71,8 @@
top: 0;
z-index: 100;
width: 100%;
background-color: $grey-60;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
background: $grey-90;
border-bottom: 1px solid $grey-70;
}
.nav-container {
@ -222,7 +125,6 @@
gap: $unit;
text-decoration: none;
color: $grey-30;
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
font-weight: 400;
font-size: 0.925rem;
transition: color 0.2s ease;
@ -281,7 +183,6 @@
text-decoration: none;
font-size: 0.925rem;
font-weight: 500;
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
color: $grey-30;
transition: all 0.2s ease;
position: relative;

View file

@ -35,7 +35,6 @@
border: none;
border-radius: 50px;
font-size: 0.925rem;
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
color: $grey-40;
cursor: pointer;
transition: all 0.2s ease;

View file

@ -175,7 +175,6 @@
text-decoration: none;
font-size: 1rem;
font-weight: 400;
font-family: 'cstd', 'Helvetica Neue', Helvetica, Arial, sans-serif;
color: $grey-20;
position: relative;
z-index: 2;
@ -251,7 +250,6 @@
border: none;
text-align: left;
font-size: 0.925rem;
font-family: 'cstd', 'Helvetica Neue', Helvetica, Arial, sans-serif;
color: $grey-20;
cursor: pointer;
transition: background-color 0.2s ease;

View 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>

View file

@ -1,5 +1,6 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte'
import Button from './Button.svelte'
interface Props {
title?: string
@ -38,12 +39,12 @@
<h2>{title}</h2>
<p>{message}</p>
<div class="modal-actions">
<button class="btn btn-secondary" onclick={handleCancel}>
<Button variant="secondary" onclick={handleCancel}>
{cancelText}
</button>
<button class="btn btn-danger" onclick={handleConfirm}>
</Button>
<Button variant="danger" onclick={handleConfirm}>
{confirmText}
</button>
</Button>
</div>
</div>
</div>
@ -75,14 +76,12 @@
font-size: 1.25rem;
font-weight: 700;
color: $grey-10;
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
}
p {
margin: 0 0 $unit-4x;
color: $grey-20;
line-height: 1.5;
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
}
}
@ -91,33 +90,4 @@
gap: $unit-2x;
justify-content: flex-end;
}
.btn {
padding: $unit-2x $unit-3x;
border-radius: 50px;
text-decoration: none;
font-size: 0.925rem;
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
transition: all 0.2s ease;
border: none;
cursor: pointer;
&.btn-secondary {
background-color: $grey-85;
color: $grey-20;
&:hover {
background-color: $grey-80;
}
}
&.btn-danger {
background-color: $red-60;
color: white;
&:hover {
background-color: $red-40;
}
}
}
</style>

View file

@ -12,6 +12,7 @@
autofocus?: boolean
class?: string
showToolbar?: boolean
simpleMode?: boolean
}
let {
@ -25,7 +26,8 @@
minHeight = 400,
autofocus = false,
class: className = '',
showToolbar = true
showToolbar = true,
simpleMode = false
}: Props = $props()
let editor = $state<Editor | undefined>()
@ -65,7 +67,12 @@
// Focus on mount if requested
$effect(() => {
if (editor && autofocus) {
// Only focus once on initial mount
const timer = setTimeout(() => {
editor.commands.focus()
}, 100)
return () => clearTimeout(timer)
}
})
</script>
@ -77,11 +84,11 @@
content={data}
{onUpdate}
editable={!readOnly}
{showToolbar}
showToolbar={!simpleMode && showToolbar}
{placeholder}
showSlashCommands={true}
showLinkBubbleMenu={true}
showTableBubbleMenu={true}
showSlashCommands={!simpleMode}
showLinkBubbleMenu={!simpleMode}
showTableBubbleMenu={false}
class="editor-content"
/>
</div>
@ -171,7 +178,6 @@
}
:global(.edra .ProseMirror) {
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
font-size: 16px;
line-height: 1.6;
color: $grey-10;
@ -180,7 +186,6 @@
}
:global(.edra .ProseMirror h1) {
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
font-size: 2rem;
font-weight: 700;
margin: $unit-3x 0 $unit-2x;
@ -188,7 +193,6 @@
}
:global(.edra .ProseMirror h2) {
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
font-size: 1.5rem;
font-weight: 600;
margin: $unit-3x 0 $unit-2x;
@ -196,7 +200,6 @@
}
:global(.edra .ProseMirror h3) {
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
font-size: 1.25rem;
font-weight: 600;
margin: $unit-3x 0 $unit-2x;
@ -335,7 +338,6 @@
:global(.edra-media-placeholder-text) {
font-size: 1rem;
color: $grey-30;
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
}
// Image container styles
@ -372,7 +374,6 @@
border-radius: 4px;
font-size: 0.875rem;
color: $grey-30;
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
background: $grey-95;
&:focus {

View file

@ -86,15 +86,15 @@
// Group basic formatting first
const basicOrder = ['bold', 'italic', 'underline', 'strike']
basicOrder.forEach(name => {
const cmd = allCommands.find(c => c.name === name)
basicOrder.forEach((name) => {
const cmd = allCommands.find((c) => c.name === name)
if (cmd) basicFormatting.push(cmd)
})
// Then link and code
const advancedOrder = ['link', 'code']
advancedOrder.forEach(name => {
const cmd = allCommands.find(c => c.name === name)
advancedOrder.forEach((name) => {
const cmd = allCommands.find((c) => c.name === name)
if (cmd) advancedFormatting.push(cmd)
})
@ -121,7 +121,7 @@
// Get media commands, but filter out iframe
const getMediaCommands = () => {
if (commands.media) {
return commands.media.commands.filter(cmd => cmd.name !== 'iframe-placeholder')
return commands.media.commands.filter((cmd) => cmd.name !== 'iframe-placeholder')
}
return []
}
@ -341,14 +341,22 @@
<div class="edra-toolbar">
<!-- Text Style Dropdown -->
<div class="text-style-dropdown">
<button
bind:this={dropdownTriggerRef}
class="dropdown-trigger"
onclick={toggleDropdown}
>
<button bind:this={dropdownTriggerRef} class="dropdown-trigger" onclick={toggleDropdown}>
<span>{getCurrentTextStyle(editor)}</span>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3 4.5L6 7.5L9 4.5"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
</div>
@ -371,8 +379,20 @@
onclick={toggleMediaDropdown}
>
<span>Insert</span>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3 4.5L6 7.5L9 4.5"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
</div>
@ -384,14 +404,14 @@
{editor}
style={`color: ${editor.getAttributes('textStyle').color};`}
onclick={() => {
const color = editor.getAttributes('textStyle').color;
const hasColor = editor.isActive('textStyle', { color });
const color = editor.getAttributes('textStyle').color
const hasColor = editor.isActive('textStyle', { color })
if (hasColor) {
editor.chain().focus().unsetColor().run();
editor.chain().focus().unsetColor().run()
} else {
const color = prompt('Enter the color of the text:');
const color = prompt('Enter the color of the text:')
if (color !== null) {
editor.chain().focus().setColor(color).run();
editor.chain().focus().setColor(color).run()
}
}
}}
@ -401,13 +421,13 @@
{editor}
style={`background-color: ${editor.getAttributes('highlight').color};`}
onclick={() => {
const hasHightlight = editor.isActive('highlight');
const hasHightlight = editor.isActive('highlight')
if (hasHightlight) {
editor.chain().focus().unsetHighlight().run();
editor.chain().focus().unsetHighlight().run()
} else {
const color = prompt('Enter the color of the highlight:');
const color = prompt('Enter the color of the highlight:')
if (color !== null) {
editor.chain().focus().setHighlight({ color }).run();
editor.chain().focus().setHighlight({ color }).run()
}
}
}}
@ -450,33 +470,72 @@
style="position: fixed; top: {mediaDropdownPosition.top}px; left: {mediaDropdownPosition.left}px; z-index: 10000;"
>
<div class="dropdown-menu">
<button class="dropdown-item" onclick={() => {
<button
class="dropdown-item"
onclick={() => {
editor?.chain().focus().insertImagePlaceholder().run()
showMediaDropdown = false
}}>
}}
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" class="dropdown-icon">
<rect x="3" y="5" width="14" height="10" stroke="currentColor" stroke-width="2" fill="none" rx="1"/>
<rect
x="3"
y="5"
width="14"
height="10"
stroke="currentColor"
stroke-width="2"
fill="none"
rx="1"
/>
<circle cx="7" cy="9" r="1.5" stroke="currentColor" stroke-width="2" fill="none" />
<path d="M3 12L7 8L10 11L13 8L17 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
<path
d="M3 12L7 8L10 11L13 8L17 12"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
fill="none"
/>
</svg>
<span>Image</span>
</button>
<button class="dropdown-item" onclick={() => {
<button
class="dropdown-item"
onclick={() => {
editor?.chain().focus().insertVideoPlaceholder().run()
showMediaDropdown = false
}}>
}}
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" class="dropdown-icon">
<rect x="3" y="4" width="14" height="12" stroke="currentColor" stroke-width="2" fill="none" rx="2"/>
<rect
x="3"
y="4"
width="14"
height="12"
stroke="currentColor"
stroke-width="2"
fill="none"
rx="2"
/>
<path d="M8 8.5L12 10L8 11.5V8.5Z" fill="currentColor" />
</svg>
<span>Video</span>
</button>
<button class="dropdown-item" onclick={() => {
<button
class="dropdown-item"
onclick={() => {
editor?.chain().focus().insertAudioPlaceholder().run()
showMediaDropdown = false
}}>
}}
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" class="dropdown-icon">
<path d="M10 4L10 16M6 8L6 12M14 8L14 12M2 6L2 14M18 6L18 14" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<path
d="M10 4L10 16M6 8L6 12M14 8L14 12M2 6L2 14M18 6L18 14"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
<span>Audio</span>
</button>
@ -491,61 +550,88 @@
style="position: fixed; top: {dropdownPosition.top}px; left: {dropdownPosition.left}px; z-index: 10000;"
>
<div class="dropdown-menu">
<button class="dropdown-item" onclick={() => {
<button
class="dropdown-item"
onclick={() => {
editor?.chain().focus().setParagraph().run()
showTextStyleDropdown = false
}}>
}}
>
Paragraph
</button>
<div class="dropdown-separator"></div>
<button class="dropdown-item" onclick={() => {
<button
class="dropdown-item"
onclick={() => {
editor?.chain().focus().toggleHeading({ level: 1 }).run()
showTextStyleDropdown = false
}}>
}}
>
Heading 1
</button>
<button class="dropdown-item" onclick={() => {
<button
class="dropdown-item"
onclick={() => {
editor?.chain().focus().toggleHeading({ level: 2 }).run()
showTextStyleDropdown = false
}}>
}}
>
Heading 2
</button>
<button class="dropdown-item" onclick={() => {
<button
class="dropdown-item"
onclick={() => {
editor?.chain().focus().toggleHeading({ level: 3 }).run()
showTextStyleDropdown = false
}}>
}}
>
Heading 3
</button>
<div class="dropdown-separator"></div>
<button class="dropdown-item" onclick={() => {
<button
class="dropdown-item"
onclick={() => {
editor?.chain().focus().toggleBulletList().run()
showTextStyleDropdown = false
}}>
}}
>
Unordered List
</button>
<button class="dropdown-item" onclick={() => {
<button
class="dropdown-item"
onclick={() => {
editor?.chain().focus().toggleOrderedList().run()
showTextStyleDropdown = false
}}>
}}
>
Ordered List
</button>
<button class="dropdown-item" onclick={() => {
<button
class="dropdown-item"
onclick={() => {
editor?.chain().focus().toggleTaskList().run()
showTextStyleDropdown = false
}}>
}}
>
Task List
</button>
<div class="dropdown-separator"></div>
<button class="dropdown-item" onclick={() => {
<button
class="dropdown-item"
onclick={() => {
editor?.chain().focus().toggleCodeBlock().run()
showTextStyleDropdown = false
}}>
}}
>
Code Block
</button>
<button class="dropdown-item" onclick={() => {
<button
class="dropdown-item"
onclick={() => {
editor?.chain().focus().toggleBlockquote().run()
showTextStyleDropdown = false
}}>
}}
>
Blockquote
</button>
</div>
@ -693,7 +779,7 @@
}
:global(.edra-toolbar button.active),
:global(.edra-toolbar button[data-active="true"]) {
:global(.edra-toolbar button[data-active='true']) {
background: rgba(0, 0, 0, 0.1);
border-color: transparent;
}

View 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>

View file

@ -50,7 +50,6 @@
margin-bottom: $unit;
font-weight: 500;
color: $grey-20;
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
font-size: 0.925rem;
.required {
@ -63,13 +62,11 @@
margin-top: $unit;
color: #c33;
font-size: 0.875rem;
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
}
.help-text {
margin-top: $unit;
color: $grey-40;
font-size: 0.875rem;
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
}
</style>

View 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>

View 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>

View 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>

View file

@ -79,13 +79,17 @@
// Insert the uploaded image with reasonable default width
const displayWidth = media.width && media.width > 600 ? 600 : media.width
editor.chain().focus().setImage({
editor
.chain()
.focus()
.setImage({
src: media.url,
alt: media.filename || '',
width: displayWidth,
height: media.height,
align: 'center'
}).run()
})
.run()
} catch (error) {
console.error('Image upload failed:', error)
alert('Failed to upload image. Please try again.')
@ -141,9 +145,7 @@
<span class="edra-media-placeholder-text">
{isDragging ? 'Drop image here' : 'Click to upload or drag & drop'}
</span>
<span class="edra-media-placeholder-subtext">
or paste from clipboard
</span>
<span class="edra-media-placeholder-subtext"> or paste from clipboard </span>
</span>
</NodeViewWrapper>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View file

@ -1,20 +1,46 @@
<script lang="ts">
import { goto } from '$app/navigation'
import UniverseComposer from './UniverseComposer.svelte'
import Button from './Button.svelte'
let isOpen = $state(false)
let buttonRef: HTMLButtonElement
let buttonRef: HTMLElement
let showComposer = $state(false)
let selectedType = $state<'post' | 'essay' | 'album'>('post')
const postTypes = [
{ value: 'blog', label: '📝 Blog Post', description: 'Long-form article' },
{ value: 'microblog', label: '💭 Microblog', description: 'Short thought' },
{ value: 'link', label: '🔗 Link', description: 'Share a link' },
{ value: 'photo', label: '📷 Photo', description: 'Single photo post' },
{ value: 'album', label: '🖼️ Album', description: 'Photo collection' }
{ value: 'blog', label: 'Essay' },
{ value: 'microblog', label: 'Post' },
{ value: 'link', label: 'Link' },
{ value: 'photo', label: 'Photo' },
{ value: 'album', label: 'Album' }
]
function handleSelection(type: string) {
isOpen = false
goto(`/admin/posts/new?type=${type}`)
if (type === 'blog') {
// Essays go straight to the full page
goto('/admin/universe/compose?type=essay')
} else if (type === 'microblog' || type === 'link') {
// Posts and links open in modal
selectedType = 'post'
showComposer = true
} else if (type === 'photo' || type === 'album') {
// Photos and albums will be handled later
selectedType = 'album'
showComposer = true
}
}
function handleComposerClose() {
showComposer = false
}
function handleComposerSaved() {
showComposer = false
// Reload posts - in a real app, you'd emit an event to parent
window.location.reload()
}
function handleClickOutside(event: MouseEvent) {
@ -32,35 +58,150 @@
</script>
<div class="dropdown-container">
<button
<Button
bind:this={buttonRef}
class="btn btn-primary"
onclick={(e) => { e.stopPropagation(); isOpen = !isOpen }}
variant="primary"
onclick={(e) => {
e.stopPropagation()
isOpen = !isOpen
}}
iconPosition="right"
>
New Post
{#snippet icon()}
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" class="chevron">
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path
d="M3 4.5L6 7.5L9 4.5"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
{/snippet}
</Button>
{#if isOpen}
<div class="dropdown-menu">
{#each postTypes as type}
<button
class="dropdown-item"
<Button
variant="ghost"
onclick={() => handleSelection(type.value)}
class="dropdown-item"
fullWidth
pill={false}
>
<span class="dropdown-icon">{type.label}</span>
<div class="dropdown-text">
<span class="dropdown-label">{type.label.split(' ')[1]}</span>
<span class="dropdown-description">{type.description}</span>
{#snippet icon()}
<div class="dropdown-icon">
{#if type.value === 'blog'}
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path
d="M3 5C3 3.89543 3.89543 3 5 3H11L17 9V15C17 16.1046 16.1046 17 15 17H5C3.89543 17 3 16.1046 3 15V5Z"
stroke="currentColor"
stroke-width="1.5"
/>
<path d="M11 3V9H17" stroke="currentColor" stroke-width="1.5" />
<path
d="M7 13H13"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
/>
<path
d="M7 10H13"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
/>
</svg>
{:else if type.value === 'microblog'}
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path
d="M4 3C2.89543 3 2 3.89543 2 5V11C2 12.1046 2.89543 13 4 13H6L8 16V13H13C14.1046 13 15 12.1046 15 11V5C15 3.89543 14.1046 3 13 3H4Z"
stroke="currentColor"
stroke-width="1.5"
/>
<path d="M5 7H12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
<path d="M5 9H10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
{:else if type.value === 'link'}
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path
d="M10 5H7C4.79086 5 3 6.79086 3 9C3 11.2091 4.79086 13 7 13H10M10 7H13C15.2091 7 17 8.79086 17 11C17 13.2091 15.2091 15 13 15H10"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
/>
<path
d="M7 10H13"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
/>
</svg>
{:else if type.value === 'photo'}
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<rect
x="3"
y="3"
width="14"
height="14"
rx="2"
stroke="currentColor"
stroke-width="1.5"
/>
<circle cx="8" cy="8" r="1.5" stroke="currentColor" stroke-width="1.5" />
<path
d="M3 14L7 10L10 13L13 10L17 14"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
{:else if type.value === 'album'}
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<rect
x="3"
y="5"
width="14"
height="12"
rx="2"
stroke="currentColor"
stroke-width="1.5"
/>
<path
d="M5 5V3C5 1.89543 5.89543 1 7 1H13C14.1046 1 15 1.89543 15 3V5"
stroke="currentColor"
stroke-width="1.5"
/>
<circle cx="8" cy="10" r="1.5" stroke="currentColor" stroke-width="1.5" />
<path
d="M3 14L7 11L10 13L13 11L17 14"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
{/if}
</div>
</button>
{/snippet}
<span class="dropdown-label">{type.label}</span>
</Button>
{/each}
</div>
{/if}
</div>
<UniverseComposer
bind:isOpen={showComposer}
initialPostType={selectedType}
on:close={handleComposerClose}
on:saved={handleComposerSaved}
on:switch-to-essay
/>
<style lang="scss">
@import '$styles/variables.scss';
@ -68,25 +209,17 @@
position: relative;
}
.btn {
padding: $unit-2x $unit-3x;
border: none;
border-radius: 50px;
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
font-size: 0.925rem;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: $unit;
&.btn-primary {
// Button styles are now handled by the Button component
// Override primary button color to match original design
:global(.dropdown-container .btn-primary) {
background-color: $grey-10;
color: white;
&:hover {
&:hover:not(:disabled) {
background-color: $grey-20;
}
&:active:not(:disabled) {
background-color: $grey-30;
}
}
@ -99,55 +232,37 @@
top: calc(100% + $unit);
right: 0;
background: white;
border: 1px solid $grey-80;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
min-width: 200px;
border: 1px solid $grey-85;
border-radius: $unit-2x;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
min-width: 220px;
z-index: 100;
overflow: hidden;
}
.dropdown-item {
width: 100%;
padding: $unit-2x;
border: none;
background: none;
cursor: pointer;
display: flex;
align-items: center;
gap: $unit-2x;
// Override Button component styles for dropdown items
:global(.dropdown-item) {
justify-content: flex-start;
text-align: left;
transition: background 0.2s ease;
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
&:hover {
background: $grey-95;
}
&:not(:last-child) {
border-bottom: 1px solid $grey-90;
}
padding: $unit-2x $unit-3x;
border-radius: 0;
}
.dropdown-icon {
font-size: 1.25rem;
flex-shrink: 0;
}
.dropdown-text {
color: $grey-40;
display: flex;
flex-direction: column;
gap: 2px;
align-items: center;
flex-shrink: 0;
svg {
width: 20px;
height: 20px;
}
}
.dropdown-label {
font-size: 0.925rem;
font-weight: 600;
font-weight: 500;
color: $grey-10;
}
.dropdown-description {
font-size: 0.75rem;
color: $grey-40;
}
</style>

View file

@ -1,60 +1,69 @@
<script lang="ts">
import FormFieldWrapper from './FormFieldWrapper.svelte'
import Input from './Input.svelte'
import ImageUploader from './ImageUploader.svelte'
import type { ProjectFormData } from '$lib/types/project'
import type { Media } from '@prisma/client'
interface Props {
formData: ProjectFormData
logoUploadInProgress: boolean
onLogoUpload: (event: Event) => void
onRemoveLogo: () => void
}
let { formData = $bindable(), logoUploadInProgress, onLogoUpload, onRemoveLogo }: Props = $props()
let { formData = $bindable() }: Props = $props()
// Convert logoUrl string to Media object for ImageUploader
let logoMedia = $state<Media | null>(null)
// Update logoMedia when logoUrl changes
$effect(() => {
if (formData.logoUrl && !logoMedia) {
// Create a minimal Media object from the URL for display
logoMedia = {
id: -1, // Temporary ID for existing URLs
filename: 'logo.svg',
originalName: 'logo.svg',
mimeType: 'image/svg+xml',
size: 0,
url: formData.logoUrl,
thumbnailUrl: formData.logoUrl,
width: null,
height: null,
altText: null,
description: null,
usedIn: [],
createdAt: new Date(),
updatedAt: new Date()
}
} else if (!formData.logoUrl) {
logoMedia = null
}
})
function handleLogoUpload(media: Media) {
formData.logoUrl = media.url
logoMedia = media
}
function handleLogoRemove() {
formData.logoUrl = ''
logoMedia = null
}
</script>
<div class="form-section">
<h2>Branding</h2>
<FormFieldWrapper
label="Logo"
helpText="SVG logo for project thumbnail (max 500KB)"
>
<div class="logo-upload-wrapper">
{#if formData.logoUrl}
<div class="logo-preview">
<img src={formData.logoUrl} alt="Project logo" />
<button
type="button"
class="remove-logo"
onclick={onRemoveLogo}
aria-label="Remove logo"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M12 4L4 12M4 4L12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</button>
</div>
{:else}
<label class="logo-upload-placeholder">
<input
type="file"
accept="image/svg+xml"
onchange={onLogoUpload}
disabled={logoUploadInProgress}
<ImageUploader
label="Project Logo"
value={logoMedia}
onUpload={handleLogoUpload}
aspectRatio="1:1"
allowAltText={true}
maxFileSize={0.5}
placeholder="Drag and drop an SVG logo here, or click to browse"
helpText="Upload an SVG logo for project thumbnail (max 500KB). Square logos work best."
showBrowseLibrary={true}
compact={true}
/>
{#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>
<style lang="scss">
@ -70,117 +79,6 @@
font-weight: 600;
margin: 0 0 $unit-3x;
color: $grey-10;
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
}
}
.logo-upload-wrapper {
display: flex;
align-items: center;
gap: $unit-2x;
}
.logo-preview {
position: relative;
width: 120px;
height: 120px;
background: $grey-95;
border: 1px solid $grey-80;
border-radius: $unit-2x;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
img {
max-width: 80%;
max-height: 80%;
object-fit: contain;
}
.remove-logo {
position: absolute;
top: $unit;
right: $unit;
width: 32px;
height: 32px;
background: white;
border: 1px solid $grey-80;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
opacity: 0;
&:hover {
background: $grey-95;
border-color: $grey-60;
}
}
&:hover .remove-logo {
opacity: 1;
}
}
.logo-upload-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: $unit;
width: 200px;
height: 120px;
background: $grey-97;
border: 2px dashed $grey-80;
border-radius: $unit-2x;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
overflow: hidden;
input[type="file"] {
position: absolute;
opacity: 0;
width: 100%;
height: 100%;
cursor: pointer;
}
svg {
color: $grey-50;
}
span {
font-size: 0.875rem;
color: $grey-30;
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
}
&:hover {
background: $grey-95;
border-color: $grey-60;
svg {
color: $grey-40;
}
span {
color: $grey-20;
}
}
&:has(input:disabled) {
cursor: not-allowed;
opacity: 0.6;
}
}
.upload-loading {
font-size: 0.875rem;
color: $grey-40;
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
}
</style>

View file

@ -1,5 +1,4 @@
<script lang="ts">
import { onMount } from 'svelte'
import { goto } from '$app/navigation'
import { z } from 'zod'
import AdminPage from './AdminPage.svelte'
@ -8,7 +7,9 @@
import Editor from './Editor.svelte'
import ProjectMetadataForm from './ProjectMetadataForm.svelte'
import ProjectBrandingForm from './ProjectBrandingForm.svelte'
import ProjectGalleryForm from './ProjectGalleryForm.svelte'
import ProjectStylingForm from './ProjectStylingForm.svelte'
import Button from './Button.svelte'
import { projectSchema } from '$lib/schemas/project'
import type { Project, ProjectFormData } from '$lib/types/project'
import { defaultProjectFormData } from '$lib/types/project'
@ -31,7 +32,6 @@
// Form data
let formData = $state<ProjectFormData>({ ...defaultProjectFormData })
let logoUploadInProgress = $state(false)
// Ref to the editor component
let editorRef: any
@ -41,11 +41,11 @@
{ value: 'case-study', label: 'Case Study' }
]
onMount(() => {
if (project) {
// Watch for project changes and populate form data
$effect(() => {
if (project && mode === 'edit') {
populateFormData(project)
}
if (mode === 'create') {
} else if (mode === 'create') {
isLoading = false
}
})
@ -60,9 +60,11 @@
role: data.role || '',
technologies: Array.isArray(data.technologies) ? data.technologies.join(', ') : '',
externalUrl: data.externalUrl || '',
featuredImage: data.featuredImage || null,
backgroundColor: data.backgroundColor || '',
highlightColor: data.highlightColor || '',
logoUrl: data.logoUrl || '',
gallery: data.gallery || null,
status: (data.status as 'draft' | 'published') || 'draft',
caseStudyContent: data.caseStudyContent || {
type: 'doc',
@ -104,68 +106,6 @@
formData.caseStudyContent = content
}
async function handleLogoUpload(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
// Check if it's an SVG
if (file.type !== 'image/svg+xml') {
error = 'Please upload an SVG file'
return
}
// Check file size (500KB max for SVG)
const filesize = file.size / 1024 / 1024
if (filesize > 0.5) {
error = `Logo file too large! File size: ${filesize.toFixed(2)} MB (max 0.5MB)`
return
}
try {
logoUploadInProgress = true
error = ''
const auth = localStorage.getItem('admin_auth')
if (!auth) {
goto('/admin/login')
return
}
const uploadFormData = new FormData()
uploadFormData.append('file', file)
const response = await fetch('/api/media/upload', {
method: 'POST',
headers: {
Authorization: `Basic ${auth}`
},
body: uploadFormData
})
if (!response.ok) {
throw new Error('Upload failed')
}
const media = await response.json()
formData.logoUrl = media.url
successMessage = 'Logo uploaded successfully!'
setTimeout(() => {
successMessage = ''
}, 3000)
} catch (err) {
error = 'Failed to upload logo'
console.error(err)
} finally {
logoUploadInProgress = false
}
}
function removeLogo() {
formData.logoUrl = ''
}
async function handleSave() {
// Check if we're on the case study tab and should save editor content
@ -204,12 +144,16 @@
.map((t) => t.trim())
.filter(Boolean),
externalUrl: formData.externalUrl,
featuredImage: formData.featuredImage,
logoUrl: formData.logoUrl,
gallery: formData.gallery && formData.gallery.length > 0 ? formData.gallery : null,
backgroundColor: formData.backgroundColor,
highlightColor: formData.highlightColor,
status: formData.status,
caseStudyContent:
formData.caseStudyContent && formData.caseStudyContent.content && formData.caseStudyContent.content.length > 0
formData.caseStudyContent &&
formData.caseStudyContent.content &&
formData.caseStudyContent.content.length > 0
? formData.caseStudyContent
: null
}
@ -296,16 +240,20 @@
<div class="header-actions">
{#if !isLoading}
<div class="save-actions">
<button onclick={handleSave} disabled={isSaving} class="btn btn-primary save-button">
<Button variant="primary" onclick={handleSave} disabled={isSaving} class="save-button">
{isSaving ? 'Saving...' : formData.status === 'published' ? 'Save' : 'Save Draft'}
</button>
<button
class="btn btn-primary chevron-button"
class:active={showPublishMenu}
</Button>
<Button
variant="ghost"
iconOnly
size="medium"
active={showPublishMenu}
onclick={togglePublishMenu}
disabled={isSaving}
class="chevron-button"
>
<svg
slot="icon"
width="12"
height="12"
viewBox="0 0 12 12"
@ -320,13 +268,17 @@
stroke-linejoin="round"
/>
</svg>
</button>
</Button>
{#if showPublishMenu}
<div class="publish-menu">
{#if formData.status === 'published'}
<button onclick={handleUnpublish} class="menu-item"> Unpublish </button>
<Button variant="ghost" onclick={handleUnpublish} class="menu-item" fullWidth>
Unpublish
</Button>
{:else}
<button onclick={handlePublish} class="menu-item"> Publish </button>
<Button variant="ghost" onclick={handlePublish} class="menu-item" fullWidth>
Publish
</Button>
{/if}
</div>
{/if}
@ -358,12 +310,8 @@
}}
>
<ProjectMetadataForm bind:formData {validationErrors} />
<ProjectBrandingForm
bind:formData
bind:logoUploadInProgress
onLogoUpload={handleLogoUpload}
onRemoveLogo={removeLogo}
/>
<ProjectBrandingForm bind:formData />
<ProjectGalleryForm bind:formData />
<ProjectStylingForm bind:formData {validationErrors} />
</form>
</div>
@ -427,49 +375,23 @@
.save-actions {
position: relative;
display: flex;
gap: $unit-half;
}
.btn {
padding: $unit $unit-3x;
border-radius: 50px;
text-decoration: none;
font-size: 0.925rem;
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
transition: all 0.2s ease;
border: none;
cursor: pointer;
/* Button-specific styles handled by Button component */
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
&.btn-primary {
background-color: $grey-10;
color: white;
&:hover:not(:disabled) {
background-color: $grey-20;
}
}
}
.save-button {
/* Custom button styles */
:global(.save-button) {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
padding-right: $unit-2x;
}
.chevron-button {
:global(.chevron-button) {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
padding: $unit $unit;
border-left: 1px solid rgba(255, 255, 255, 0.2);
&.active {
background-color: $grey-20;
}
svg {
display: block;
transition: transform 0.2s ease;
@ -492,22 +414,10 @@
min-width: 120px;
z-index: 100;
.menu-item {
display: block;
width: 100%;
padding: $unit-2x $unit-3x;
/* Menu item styles handled by Button component */
:global(.menu-item) {
text-align: left;
background: none;
border: none;
font-size: 0.925rem;
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
color: $grey-10;
cursor: pointer;
transition: background-color 0.2s ease;
&:hover {
background-color: $grey-95;
}
justify-content: flex-start;
}
}
@ -537,7 +447,6 @@
text-align: center;
padding: $unit-6x;
color: $grey-40;
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
}
.error {
@ -549,7 +458,6 @@
padding: $unit-3x;
border-radius: $unit;
margin-bottom: $unit-4x;
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
max-width: 700px;
margin-left: auto;
margin-right: auto;
@ -571,6 +479,12 @@
}
}
.form-content form {
display: flex;
flex-direction: column;
gap: $unit-6x;
}
.case-study-wrapper {
background: white;
padding: 0;

View 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>

View file

@ -169,7 +169,6 @@
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
}
.project-metadata {
@ -178,7 +177,6 @@
gap: $unit;
font-size: 0.875rem;
color: $grey-40;
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
.status {
&.published {
@ -239,7 +237,6 @@
color: $grey-20;
cursor: pointer;
transition: background-color 0.2s ease;
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
&:hover {
background-color: $grey-95;

View file

@ -1,5 +1,6 @@
<script lang="ts">
import FormFieldWrapper from './FormFieldWrapper.svelte'
import Input from './Input.svelte'
import ImageUploader from './ImageUploader.svelte'
import type { ProjectFormData } from '$lib/types/project'
interface Props {
@ -8,45 +9,71 @@
}
let { formData = $bindable(), validationErrors }: Props = $props()
function handleFeaturedImageUpload(media: Media) {
formData.featuredImage = media.url
}
</script>
<div class="form-section">
<FormFieldWrapper label="Title" required error={validationErrors.title}>
<input type="text" bind:value={formData.title} required placeholder="Project title" />
</FormFieldWrapper>
<Input
label="Title"
required
error={validationErrors.title}
bind:value={formData.title}
placeholder="Project title"
/>
<FormFieldWrapper label="Description" error={validationErrors.description}>
<textarea
<Input
type="textarea"
label="Description"
error={validationErrors.description}
bind:value={formData.description}
rows="3"
rows={3}
placeholder="Short description for project cards"
/>
</FormFieldWrapper>
<div class="form-row">
<FormFieldWrapper label="Year" required error={validationErrors.year}>
<input
<Input
type="number"
bind:value={formData.year}
label="Year"
required
min="1990"
error={validationErrors.year}
bind:value={formData.year}
min={1990}
max={new Date().getFullYear() + 1}
/>
</FormFieldWrapper>
<FormFieldWrapper label="Client" error={validationErrors.client}>
<input type="text" bind:value={formData.client} placeholder="Client or company name" />
</FormFieldWrapper>
<Input
label="Client"
error={validationErrors.client}
bind:value={formData.client}
placeholder="Client or company name"
/>
</div>
<FormFieldWrapper label="External URL" error={validationErrors.externalUrl}>
<input type="url" bind:value={formData.externalUrl} placeholder="https://example.com" />
</FormFieldWrapper>
<Input
type="url"
label="External URL"
error={validationErrors.externalUrl}
bind:value={formData.externalUrl}
placeholder="https://example.com"
/>
<ImageUploader
label="Featured Image"
value={null}
onUpload={handleFeaturedImageUpload}
placeholder="Upload a featured image for this project"
showBrowseLibrary={true}
/>
</div>
<style lang="scss">
.form-section {
margin-bottom: $unit-6x;
display: flex;
flex-direction: column;
gap: $unit-2x;
&:last-child {
margin-bottom: 0;
@ -62,39 +89,5 @@
@include breakpoint('phone') {
grid-template-columns: 1fr;
}
:global(.form-field) {
margin-bottom: 0;
}
}
input[type='text'],
input[type='url'],
input[type='number'],
textarea {
width: 100%;
box-sizing: border-box;
padding: calc($unit * 1.5);
border: 1px solid $grey-80;
border-radius: $unit;
font-size: 1rem;
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
transition: border-color 0.2s ease;
background-color: white;
color: #333;
&:focus {
outline: none;
border-color: $grey-40;
}
&::placeholder {
color: #999;
}
}
textarea {
resize: vertical;
min-height: 80px;
}
</style>

View file

@ -1,5 +1,5 @@
<script lang="ts">
import FormFieldWrapper from './FormFieldWrapper.svelte'
import Input from './Input.svelte'
import type { ProjectFormData } from '$lib/types/project'
interface Props {
@ -14,44 +14,27 @@
<h2>Styling</h2>
<div class="form-row">
<FormFieldWrapper
<Input
type="text"
bind:value={formData.backgroundColor}
label="Background Color"
helpText="Hex color for project card"
error={validationErrors.backgroundColor}
>
<div class="color-input-wrapper">
<input
type="text"
bind:value={formData.backgroundColor}
placeholder="#FFFFFF"
pattern="^#[0-9A-Fa-f]{6}$"
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"
helpText="Accent color for the project"
error={validationErrors.highlightColor}
>
<div class="color-input-wrapper">
<input
type="text"
bind:value={formData.highlightColor}
placeholder="#000000"
pattern="^#[0-9A-Fa-f]{6}$"
colorSwatch={true}
/>
{#if formData.highlightColor}
<div class="color-preview" style="background-color: {formData.highlightColor}"></div>
{/if}
</div>
</FormFieldWrapper>
</div>
</div>
@ -68,7 +51,6 @@
font-weight: 600;
margin: 0 0 $unit-3x;
color: $grey-10;
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
}
}
@ -81,45 +63,8 @@
grid-template-columns: 1fr;
}
:global(.form-field) {
:global(.input-wrapper) {
margin-bottom: 0;
}
}
.color-input-wrapper {
display: flex;
align-items: center;
gap: $unit-2x;
input {
flex: 1;
width: 100%;
box-sizing: border-box;
padding: calc($unit * 1.5);
border: 1px solid $grey-80;
border-radius: $unit;
font-size: 1rem;
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
transition: border-color 0.2s ease;
background-color: white;
color: #333;
&:focus {
outline: none;
border-color: $grey-40;
}
&::placeholder {
color: #999;
}
}
.color-preview {
width: 40px;
height: 40px;
border-radius: $unit;
border: 1px solid $grey-80;
flex-shrink: 0;
}
}
</style>

View file

@ -21,7 +21,6 @@
display: flex;
align-items: center;
gap: $unit;
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
}
.color-dot {

View 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>

View 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>

View file

@ -1,5 +1,5 @@
import { isMac } from '../utils.js';
import type { EdraCommandGroup } from './types.js';
import { isMac } from '../utils.js'
import type { EdraCommandGroup } from './types.js'
export const commands: Record<string, EdraCommandGroup> = {
'undo-redo': {
@ -12,7 +12,7 @@ export const commands: Record<string, EdraCommandGroup> = {
label: 'Undo',
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Z`],
action: (editor) => {
editor.chain().focus().undo().run();
editor.chain().focus().undo().run()
}
},
{
@ -21,7 +21,7 @@ export const commands: Record<string, EdraCommandGroup> = {
label: 'Redo',
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Y`, `${isMac ? 'Cmd' : 'Ctrl'}+Shift+Z`],
action: (editor) => {
editor.chain().focus().redo().run();
editor.chain().focus().redo().run()
}
}
]
@ -36,7 +36,7 @@ export const commands: Record<string, EdraCommandGroup> = {
label: 'Heading 1',
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Alt+1`],
action: (editor) => {
editor.chain().focus().toggleHeading({ level: 1 }).run();
editor.chain().focus().toggleHeading({ level: 1 }).run()
},
isActive: (editor) => editor.isActive('heading', { level: 1 })
},
@ -46,7 +46,7 @@ export const commands: Record<string, EdraCommandGroup> = {
label: 'Heading 2',
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Alt+2`],
action: (editor) => {
editor.chain().focus().toggleHeading({ level: 2 }).run();
editor.chain().focus().toggleHeading({ level: 2 }).run()
},
isActive: (editor) => editor.isActive('heading', { level: 2 })
},
@ -56,7 +56,7 @@ export const commands: Record<string, EdraCommandGroup> = {
label: 'Heading 3',
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Alt+3`],
action: (editor) => {
editor.chain().focus().toggleHeading({ level: 3 }).run();
editor.chain().focus().toggleHeading({ level: 3 }).run()
},
isActive: (editor) => editor.isActive('heading', { level: 3 })
}
@ -72,8 +72,8 @@ export const commands: Record<string, EdraCommandGroup> = {
label: 'Link',
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+K`],
action: (editor) => {
const href = prompt('Enter the URL of the link:');
if (href !== null) editor.chain().focus().setLink({ href, target: '_blank' }).run();
const href = prompt('Enter the URL of the link:')
if (href !== null) editor.chain().focus().setLink({ href, target: '_blank' }).run()
}
},
{
@ -82,7 +82,7 @@ export const commands: Record<string, EdraCommandGroup> = {
label: 'Bold',
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+B`],
action: (editor) => {
editor.chain().focus().toggleBold().run();
editor.chain().focus().toggleBold().run()
}
},
{
@ -91,7 +91,7 @@ export const commands: Record<string, EdraCommandGroup> = {
label: 'Italic',
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+I`],
action: (editor) => {
editor.chain().focus().toggleItalic().run();
editor.chain().focus().toggleItalic().run()
}
},
{
@ -100,7 +100,7 @@ export const commands: Record<string, EdraCommandGroup> = {
label: 'Underline',
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+U`],
action: (editor) => {
editor.chain().focus().toggleUnderline().run();
editor.chain().focus().toggleUnderline().run()
}
},
{
@ -109,7 +109,7 @@ export const commands: Record<string, EdraCommandGroup> = {
label: 'Strikethrough',
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Shift+S`],
action: (editor) => {
editor.chain().focus().toggleStrike().run();
editor.chain().focus().toggleStrike().run()
}
},
{
@ -118,7 +118,7 @@ export const commands: Record<string, EdraCommandGroup> = {
label: 'Blockquote',
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Shift+B`],
action: (editor) => {
editor.chain().focus().toggleBlockquote().run();
editor.chain().focus().toggleBlockquote().run()
}
},
{
@ -127,7 +127,7 @@ export const commands: Record<string, EdraCommandGroup> = {
label: 'Superscript',
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Period`],
action: (editor) => {
editor.chain().focus().toggleSuperscript().run();
editor.chain().focus().toggleSuperscript().run()
}
},
{
@ -136,7 +136,7 @@ export const commands: Record<string, EdraCommandGroup> = {
label: 'Subscript',
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Comma`],
action: (editor) => {
editor.chain().focus().toggleSubscript().run();
editor.chain().focus().toggleSubscript().run()
}
},
{
@ -145,7 +145,7 @@ export const commands: Record<string, EdraCommandGroup> = {
label: 'Code',
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+E`],
action: (editor) => {
editor.chain().focus().toggleCode().run();
editor.chain().focus().toggleCode().run()
}
},
{
@ -154,7 +154,7 @@ export const commands: Record<string, EdraCommandGroup> = {
label: 'Code Block',
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Alt+C`],
action: (editor) => {
editor.chain().focus().toggleCodeBlock().run();
editor.chain().focus().toggleCodeBlock().run()
}
}
]
@ -169,7 +169,7 @@ export const commands: Record<string, EdraCommandGroup> = {
label: 'Align Left',
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Shift+L`],
action: (editor) => {
editor.chain().focus().setTextAlign('left').run();
editor.chain().focus().setTextAlign('left').run()
},
isActive: (editor) => editor.isActive({ textAlign: 'left' })
},
@ -179,7 +179,7 @@ export const commands: Record<string, EdraCommandGroup> = {
label: 'Align Center',
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Shift+E`],
action: (editor) => {
editor.chain().focus().setTextAlign('center').run();
editor.chain().focus().setTextAlign('center').run()
},
isActive: (editor) => editor.isActive({ textAlign: 'center' })
},
@ -189,7 +189,7 @@ export const commands: Record<string, EdraCommandGroup> = {
label: 'Align Right',
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Shift+R`],
action: (editor) => {
editor.chain().focus().setTextAlign('right').run();
editor.chain().focus().setTextAlign('right').run()
},
isActive: (editor) => editor.isActive({ textAlign: 'right' })
},
@ -199,7 +199,7 @@ export const commands: Record<string, EdraCommandGroup> = {
label: 'Align Justify',
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Shift+J`],
action: (editor) => {
editor.chain().focus().setTextAlign('justify').run();
editor.chain().focus().setTextAlign('justify').run()
},
isActive: (editor) => editor.isActive({ textAlign: 'justify' })
}
@ -215,7 +215,7 @@ export const commands: Record<string, EdraCommandGroup> = {
label: 'Bullet List',
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Shift+8`],
action: (editor) => {
editor.chain().focus().toggleBulletList().run();
editor.chain().focus().toggleBulletList().run()
}
},
{
@ -224,7 +224,7 @@ export const commands: Record<string, EdraCommandGroup> = {
label: 'Ordered List',
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Shift+7`],
action: (editor) => {
editor.chain().focus().toggleOrderedList().run();
editor.chain().focus().toggleOrderedList().run()
}
},
{
@ -233,7 +233,7 @@ export const commands: Record<string, EdraCommandGroup> = {
label: 'Task List',
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Shift+9`],
action: (editor) => {
editor.chain().focus().toggleTaskList().run();
editor.chain().focus().toggleTaskList().run()
}
}
]
@ -247,7 +247,7 @@ export const commands: Record<string, EdraCommandGroup> = {
name: 'audio-placeholder',
label: 'Audio',
action: (editor) => {
editor.chain().focus().insertAudioPlaceholder().run();
editor.chain().focus().insertAudioPlaceholder().run()
}
},
{
@ -255,7 +255,7 @@ export const commands: Record<string, EdraCommandGroup> = {
name: 'image-placeholder',
label: 'Image',
action: (editor) => {
editor.chain().focus().insertImagePlaceholder().run();
editor.chain().focus().insertImagePlaceholder().run()
}
},
{
@ -263,7 +263,7 @@ export const commands: Record<string, EdraCommandGroup> = {
name: 'video-placeholder',
label: 'Video',
action: (editor) => {
editor.chain().focus().insertVideoPlaceholder().run();
editor.chain().focus().insertVideoPlaceholder().run()
}
},
{
@ -271,7 +271,7 @@ export const commands: Record<string, EdraCommandGroup> = {
name: 'iframe-placeholder',
label: 'IFrame',
action: (editor) => {
editor.chain().focus().insertIFramePlaceholder().run();
editor.chain().focus().insertIFramePlaceholder().run()
}
}
]
@ -286,7 +286,7 @@ export const commands: Record<string, EdraCommandGroup> = {
label: 'Color',
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Shift+C`],
action: (editor) => {
editor.chain().focus().unsetColor().run();
editor.chain().focus().unsetColor().run()
}
},
{
@ -295,7 +295,7 @@ export const commands: Record<string, EdraCommandGroup> = {
label: 'Highlight',
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Shift+H`],
action: (editor) => {
editor.chain().focus().toggleHighlight().run();
editor.chain().focus().toggleHighlight().run()
}
}
]
@ -310,8 +310,8 @@ export const commands: Record<string, EdraCommandGroup> = {
label: 'Table',
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Shift+T`],
action: (editor) => {
if (editor.isActive('table')) editor.chain().focus().deleteTable().run();
else editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: false }).run();
if (editor.isActive('table')) editor.chain().focus().deleteTable().run()
else editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: false }).run()
}
}
]
@ -325,9 +325,9 @@ export const commands: Record<string, EdraCommandGroup> = {
name: 'font increment',
label: 'Increase Font Size',
action: (editor) => {
let currentFontSize = parseInt(editor.getAttributes('textStyle').fontSize ?? '16px');
currentFontSize++;
editor.chain().focus().setFontSize(`${currentFontSize}px`).run();
let currentFontSize = parseInt(editor.getAttributes('textStyle').fontSize ?? '16px')
currentFontSize++
editor.chain().focus().setFontSize(`${currentFontSize}px`).run()
}
},
{
@ -335,11 +335,11 @@ export const commands: Record<string, EdraCommandGroup> = {
name: 'font decrement',
label: 'Decrease Font Size',
action: (editor) => {
let currentFontSize = parseInt(editor.getAttributes('textStyle').fontSize ?? '16px');
currentFontSize--;
editor.chain().focus().setFontSize(`${currentFontSize}px`).run();
let currentFontSize = parseInt(editor.getAttributes('textStyle').fontSize ?? '16px')
currentFontSize--
editor.chain().focus().setFontSize(`${currentFontSize}px`).run()
}
}
]
}
};
}

View file

@ -1,21 +1,21 @@
import type { Editor } from '@tiptap/core';
import type { icons } from 'lucide-svelte';
import type { Editor } from '@tiptap/core'
import type { icons } from 'lucide-svelte'
export interface EdraCommand {
iconName: keyof typeof icons;
name: string;
label: string;
shortCuts?: string[];
action: (editor: Editor) => void;
isActive?: (editor: Editor) => boolean;
iconName: keyof typeof icons
name: string
label: string
shortCuts?: string[]
action: (editor: Editor) => void
isActive?: (editor: Editor) => boolean
}
export interface EdraCommandShortCuts {
key: string;
key: string
}
export interface EdraCommandGroup {
name: string;
label: string;
commands: EdraCommand[];
name: string
label: string
commands: EdraCommand[]
}

View file

@ -1,16 +1,16 @@
<script lang="ts">
import type { Editor } from '@tiptap/core';
import { onMount } from 'svelte';
import GripVertical from 'lucide-svelte/icons/grip-vertical';
import { DragHandlePlugin } from './extensions/drag-handle/index.js';
import type { Editor } from '@tiptap/core'
import { onMount } from 'svelte'
import GripVertical from 'lucide-svelte/icons/grip-vertical'
import { DragHandlePlugin } from './extensions/drag-handle/index.js'
interface Props {
editor: Editor;
editor: Editor
}
const { editor }: Props = $props();
const { editor }: Props = $props()
const pluginKey = 'globalDragHandle';
const pluginKey = 'globalDragHandle'
onMount(() => {
const plugin = DragHandlePlugin({
@ -20,10 +20,10 @@
dragHandleSelector: '.drag-handle',
excludedTags: ['pre', 'code', 'table p'],
customNodes: []
});
editor.registerPlugin(plugin);
return () => editor.unregisterPlugin(pluginKey);
});
})
editor.registerPlugin(plugin)
return () => editor.unregisterPlugin(pluginKey)
})
</script>
<div class="drag-handle">

View file

@ -1,28 +1,28 @@
import { Editor, type Content, type EditorOptions, type Extensions } from '@tiptap/core';
import Color from '@tiptap/extension-color';
import Link from '@tiptap/extension-link';
import Subscript from '@tiptap/extension-subscript';
import Superscript from '@tiptap/extension-superscript';
import TaskItem from '@tiptap/extension-task-item';
import TaskList from '@tiptap/extension-task-list';
import TextAlign from '@tiptap/extension-text-align';
import TextStyle from '@tiptap/extension-text-style';
import Typography from '@tiptap/extension-typography';
import Underline from '@tiptap/extension-underline';
import StarterKit from '@tiptap/starter-kit';
import Highlight from '@tiptap/extension-highlight';
import Text from '@tiptap/extension-text';
import { SmilieReplacer } from './extensions/SmilieReplacer.js';
import { ColorHighlighter } from './extensions/ColorHighlighter.js';
import AutoJoiner from 'tiptap-extension-auto-joiner';
import { MathExtension } from '@aarkue/tiptap-math-extension';
import { Table, TableCell, TableHeader, TableRow } from './extensions/table/index.js';
import FontSize from './extensions/FontSize.js';
import Placeholder from '@tiptap/extension-placeholder';
import CharacterCount from '@tiptap/extension-character-count';
import SearchAndReplace from './extensions/FindAndReplace.js';
import { getHandlePaste } from './utils.js';
import { Markdown } from 'tiptap-markdown';
import { Editor, type Content, type EditorOptions, type Extensions } from '@tiptap/core'
import Color from '@tiptap/extension-color'
import Link from '@tiptap/extension-link'
import Subscript from '@tiptap/extension-subscript'
import Superscript from '@tiptap/extension-superscript'
import TaskItem from '@tiptap/extension-task-item'
import TaskList from '@tiptap/extension-task-list'
import TextAlign from '@tiptap/extension-text-align'
import TextStyle from '@tiptap/extension-text-style'
import Typography from '@tiptap/extension-typography'
import Underline from '@tiptap/extension-underline'
import StarterKit from '@tiptap/starter-kit'
import Highlight from '@tiptap/extension-highlight'
import Text from '@tiptap/extension-text'
import { SmilieReplacer } from './extensions/SmilieReplacer.js'
import { ColorHighlighter } from './extensions/ColorHighlighter.js'
import AutoJoiner from 'tiptap-extension-auto-joiner'
import { MathExtension } from '@aarkue/tiptap-math-extension'
import { Table, TableCell, TableHeader, TableRow } from './extensions/table/index.js'
import FontSize from './extensions/FontSize.js'
import Placeholder from '@tiptap/extension-placeholder'
import CharacterCount from '@tiptap/extension-character-count'
import SearchAndReplace from './extensions/FindAndReplace.js'
import { getHandlePaste } from './utils.js'
import { Markdown } from 'tiptap-markdown'
export const initiateEditor = (
element?: HTMLElement,
@ -104,11 +104,11 @@ export const initiateEditor = (
// Use different placeholders depending on the node type:
placeholder: ({ node }) => {
if (node.type.name === 'heading') {
return 'Whats the title?';
return 'Whats the title?'
} else if (node.type.name === 'paragraph') {
return 'Press / or write something ...';
return 'Press / or write something ...'
}
return '';
return ''
}
}),
CharacterCount.configure({
@ -120,12 +120,12 @@ export const initiateEditor = (
],
autofocus: true,
...options
});
})
editor.setOptions({
editorProps: {
handlePaste: getHandlePaste(editor)
}
});
return editor;
};
})
return editor
}

View file

@ -1,7 +1,7 @@
import { Extension } from '@tiptap/core';
import { Plugin } from '@tiptap/pm/state';
import { Extension } from '@tiptap/core'
import { Plugin } from '@tiptap/pm/state'
import { findColors } from '../utils.js';
import { findColors } from '../utils.js'
export const ColorHighlighter = Extension.create({
name: 'colorHighlighter',
@ -11,18 +11,18 @@ export const ColorHighlighter = Extension.create({
new Plugin({
state: {
init(_, { doc }) {
return findColors(doc);
return findColors(doc)
},
apply(transaction, oldState) {
return transaction.docChanged ? findColors(transaction.doc) : oldState;
return transaction.docChanged ? findColors(transaction.doc) : oldState
}
},
props: {
decorations(state) {
return this.getState(state);
return this.getState(state)
}
}
})
];
]
}
});
})

View file

@ -20,10 +20,10 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import { Extension, type Range, type Dispatch } from '@tiptap/core';
import { Decoration, DecorationSet } from '@tiptap/pm/view';
import { Plugin, PluginKey, type EditorState, type Transaction } from '@tiptap/pm/state';
import { Node as PMNode } from '@tiptap/pm/model';
import { Extension, type Range, type Dispatch } from '@tiptap/core'
import { Decoration, DecorationSet } from '@tiptap/pm/view'
import { Plugin, PluginKey, type EditorState, type Transaction } from '@tiptap/pm/state'
import { Node as PMNode } from '@tiptap/pm/model'
declare module '@tiptap/core' {
interface Commands<ReturnType> {
@ -31,54 +31,54 @@ declare module '@tiptap/core' {
/**
* @description Set search term in extension.
*/
setSearchTerm: (searchTerm: string) => ReturnType;
setSearchTerm: (searchTerm: string) => ReturnType
/**
* @description Set replace term in extension.
*/
setReplaceTerm: (replaceTerm: string) => ReturnType;
setReplaceTerm: (replaceTerm: string) => ReturnType
/**
* @description Set case sensitivity in extension.
*/
setCaseSensitive: (caseSensitive: boolean) => ReturnType;
setCaseSensitive: (caseSensitive: boolean) => ReturnType
/**
* @description Reset current search result to first instance.
*/
resetIndex: () => ReturnType;
resetIndex: () => ReturnType
/**
* @description Find next instance of search result.
*/
nextSearchResult: () => ReturnType;
nextSearchResult: () => ReturnType
/**
* @description Find previous instance of search result.
*/
previousSearchResult: () => ReturnType;
previousSearchResult: () => ReturnType
/**
* @description Replace first instance of search result with given replace term.
*/
replace: () => ReturnType;
replace: () => ReturnType
/**
* @description Replace all instances of search result with given replace term.
*/
replaceAll: () => ReturnType;
};
replaceAll: () => ReturnType
}
}
}
interface TextNodesWithPosition {
text: string;
pos: number;
text: string
pos: number
}
const getRegex = (s: string, disableRegex: boolean, caseSensitive: boolean): RegExp => {
return RegExp(
disableRegex ? s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') : s,
caseSensitive ? 'gu' : 'gui'
);
};
)
}
interface ProcessedSearches {
decorationsToReturn: DecorationSet;
results: Range[];
decorationsToReturn: DecorationSet
results: Range[]
}
function processSearches(
@ -87,17 +87,17 @@ function processSearches(
searchResultClass: string,
resultIndex: number
): ProcessedSearches {
const decorations: Decoration[] = [];
const results: Range[] = [];
const decorations: Decoration[] = []
const results: Range[] = []
let textNodesWithPosition: TextNodesWithPosition[] = [];
let index = 0;
let textNodesWithPosition: TextNodesWithPosition[] = []
let index = 0
if (!searchTerm) {
return {
decorationsToReturn: DecorationSet.empty,
results: []
};
}
}
doc?.descendants((node, pos) => {
@ -106,51 +106,51 @@ function processSearches(
textNodesWithPosition[index] = {
text: textNodesWithPosition[index].text + node.text,
pos: textNodesWithPosition[index].pos
};
}
} else {
textNodesWithPosition[index] = {
text: `${node.text}`,
pos
};
}
}
} else {
index += 1;
index += 1
}
});
})
textNodesWithPosition = textNodesWithPosition.filter(Boolean);
textNodesWithPosition = textNodesWithPosition.filter(Boolean)
for (const element of textNodesWithPosition) {
const { text, pos } = element;
const matches = Array.from(text.matchAll(searchTerm)).filter(([matchText]) => matchText.trim());
const { text, pos } = element
const matches = Array.from(text.matchAll(searchTerm)).filter(([matchText]) => matchText.trim())
for (const m of matches) {
if (m[0] === '') break;
if (m[0] === '') break
if (m.index !== undefined) {
results.push({
from: pos + m.index,
to: pos + m.index + m[0].length
});
})
}
}
}
for (let i = 0; i < results.length; i += 1) {
const r = results[i];
const r = results[i]
const className =
i === resultIndex ? `${searchResultClass} ${searchResultClass}-current` : searchResultClass;
i === resultIndex ? `${searchResultClass} ${searchResultClass}-current` : searchResultClass
const decoration: Decoration = Decoration.inline(r.from, r.to, {
class: className
});
})
decorations.push(decoration);
decorations.push(decoration)
}
return {
decorationsToReturn: DecorationSet.create(doc, decorations),
results
};
}
}
const replace = (
@ -158,14 +158,14 @@ const replace = (
results: Range[],
{ state, dispatch }: { state: EditorState; dispatch: Dispatch }
) => {
const firstResult = results[0];
const firstResult = results[0]
if (!firstResult) return;
if (!firstResult) return
const { from, to } = results[0];
const { from, to } = results[0]
if (dispatch) dispatch(state.tr.insertText(replaceTerm, from, to));
};
if (dispatch) dispatch(state.tr.insertText(replaceTerm, from, to))
}
const rebaseNextResult = (
replaceTerm: string,
@ -173,69 +173,69 @@ const rebaseNextResult = (
lastOffset: number,
results: Range[]
): [number, Range[]] | null => {
const nextIndex = index + 1;
const nextIndex = index + 1
if (!results[nextIndex]) return null;
if (!results[nextIndex]) return null
const { from: currentFrom, to: currentTo } = results[index];
const { from: currentFrom, to: currentTo } = results[index]
const offset = currentTo - currentFrom - replaceTerm.length + lastOffset;
const offset = currentTo - currentFrom - replaceTerm.length + lastOffset
const { from, to } = results[nextIndex];
const { from, to } = results[nextIndex]
results[nextIndex] = {
to: to - offset,
from: from - offset
};
}
return [offset, results];
};
return [offset, results]
}
const replaceAll = (
replaceTerm: string,
results: Range[],
{ tr, dispatch }: { tr: Transaction; dispatch: Dispatch }
) => {
let offset = 0;
let offset = 0
let resultsCopy = results.slice();
let resultsCopy = results.slice()
if (!resultsCopy.length) return;
if (!resultsCopy.length) return
for (let i = 0; i < resultsCopy.length; i += 1) {
const { from, to } = resultsCopy[i];
const { from, to } = resultsCopy[i]
tr.insertText(replaceTerm, from, to);
tr.insertText(replaceTerm, from, to)
const rebaseNextResultResponse = rebaseNextResult(replaceTerm, i, offset, resultsCopy);
const rebaseNextResultResponse = rebaseNextResult(replaceTerm, i, offset, resultsCopy)
if (!rebaseNextResultResponse) continue;
if (!rebaseNextResultResponse) continue
offset = rebaseNextResultResponse[0];
resultsCopy = rebaseNextResultResponse[1];
offset = rebaseNextResultResponse[0]
resultsCopy = rebaseNextResultResponse[1]
}
if (dispatch) {
dispatch(tr);
dispatch(tr)
}
}
};
export const searchAndReplacePluginKey = new PluginKey('searchAndReplacePlugin');
export const searchAndReplacePluginKey = new PluginKey('searchAndReplacePlugin')
export interface SearchAndReplaceOptions {
searchResultClass: string;
disableRegex: boolean;
searchResultClass: string
disableRegex: boolean
}
export interface SearchAndReplaceStorage {
searchTerm: string;
replaceTerm: string;
results: Range[];
lastSearchTerm: string;
caseSensitive: boolean;
lastCaseSensitive: boolean;
resultIndex: number;
lastResultIndex: number;
searchTerm: string
replaceTerm: string
results: Range[]
lastSearchTerm: string
caseSensitive: boolean
lastCaseSensitive: boolean
resultIndex: number
lastResultIndex: number
}
export const SearchAndReplace = Extension.create<SearchAndReplaceOptions, SearchAndReplaceStorage>({
@ -245,7 +245,7 @@ export const SearchAndReplace = Extension.create<SearchAndReplaceOptions, Search
return {
searchResultClass: 'search-result',
disableRegex: true
};
}
},
addStorage() {
@ -258,7 +258,7 @@ export const SearchAndReplace = Extension.create<SearchAndReplaceOptions, Search
lastCaseSensitive: false,
resultIndex: 0,
lastResultIndex: 0
};
}
},
addCommands() {
@ -266,90 +266,90 @@ export const SearchAndReplace = Extension.create<SearchAndReplaceOptions, Search
setSearchTerm:
(searchTerm: string) =>
({ editor }) => {
editor.storage.searchAndReplace.searchTerm = searchTerm;
editor.storage.searchAndReplace.searchTerm = searchTerm
return false;
return false
},
setReplaceTerm:
(replaceTerm: string) =>
({ editor }) => {
editor.storage.searchAndReplace.replaceTerm = replaceTerm;
editor.storage.searchAndReplace.replaceTerm = replaceTerm
return false;
return false
},
setCaseSensitive:
(caseSensitive: boolean) =>
({ editor }) => {
editor.storage.searchAndReplace.caseSensitive = caseSensitive;
editor.storage.searchAndReplace.caseSensitive = caseSensitive
return false;
return false
},
resetIndex:
() =>
({ editor }) => {
editor.storage.searchAndReplace.resultIndex = 0;
editor.storage.searchAndReplace.resultIndex = 0
return false;
return false
},
nextSearchResult:
() =>
({ editor }) => {
const { results, resultIndex } = editor.storage.searchAndReplace;
const { results, resultIndex } = editor.storage.searchAndReplace
const nextIndex = resultIndex + 1;
const nextIndex = resultIndex + 1
if (results[nextIndex]) {
editor.storage.searchAndReplace.resultIndex = nextIndex;
editor.storage.searchAndReplace.resultIndex = nextIndex
} else {
editor.storage.searchAndReplace.resultIndex = 0;
editor.storage.searchAndReplace.resultIndex = 0
}
return false;
return false
},
previousSearchResult:
() =>
({ editor }) => {
const { results, resultIndex } = editor.storage.searchAndReplace;
const { results, resultIndex } = editor.storage.searchAndReplace
const prevIndex = resultIndex - 1;
const prevIndex = resultIndex - 1
if (results[prevIndex]) {
editor.storage.searchAndReplace.resultIndex = prevIndex;
editor.storage.searchAndReplace.resultIndex = prevIndex
} else {
editor.storage.searchAndReplace.resultIndex = results.length - 1;
editor.storage.searchAndReplace.resultIndex = results.length - 1
}
return false;
return false
},
replace:
() =>
({ editor, state, dispatch }) => {
const { replaceTerm, results } = editor.storage.searchAndReplace;
const { replaceTerm, results } = editor.storage.searchAndReplace
replace(replaceTerm, results, { state, dispatch });
replace(replaceTerm, results, { state, dispatch })
return false;
return false
},
replaceAll:
() =>
({ editor, tr, dispatch }) => {
const { replaceTerm, results } = editor.storage.searchAndReplace;
const { replaceTerm, results } = editor.storage.searchAndReplace
replaceAll(replaceTerm, results, { tr, dispatch });
replaceAll(replaceTerm, results, { tr, dispatch })
return false;
return false
}
}
};
},
addProseMirrorPlugins() {
const editor = this.editor;
const { searchResultClass, disableRegex } = this.options;
const editor = this.editor
const { searchResultClass, disableRegex } = this.options
const setLastSearchTerm = (t: string) => (editor.storage.searchAndReplace.lastSearchTerm = t);
const setLastSearchTerm = (t: string) => (editor.storage.searchAndReplace.lastSearchTerm = t)
const setLastCaseSensitive = (t: boolean) =>
(editor.storage.searchAndReplace.lastCaseSensitive = t);
const setLastResultIndex = (t: number) => (editor.storage.searchAndReplace.lastResultIndex = t);
(editor.storage.searchAndReplace.lastCaseSensitive = t)
const setLastResultIndex = (t: number) => (editor.storage.searchAndReplace.lastResultIndex = t)
return [
new Plugin({
@ -364,7 +364,7 @@ export const SearchAndReplace = Extension.create<SearchAndReplaceOptions, Search
lastCaseSensitive,
resultIndex,
lastResultIndex
} = editor.storage.searchAndReplace;
} = editor.storage.searchAndReplace
if (
!docChanged &&
@ -372,15 +372,15 @@ export const SearchAndReplace = Extension.create<SearchAndReplaceOptions, Search
lastCaseSensitive === caseSensitive &&
lastResultIndex === resultIndex
)
return oldState;
return oldState
setLastSearchTerm(searchTerm);
setLastCaseSensitive(caseSensitive);
setLastResultIndex(resultIndex);
setLastSearchTerm(searchTerm)
setLastCaseSensitive(caseSensitive)
setLastResultIndex(resultIndex)
if (!searchTerm) {
editor.storage.searchAndReplace.results = [];
return DecorationSet.empty;
editor.storage.searchAndReplace.results = []
return DecorationSet.empty
}
const { decorationsToReturn, results } = processSearches(
@ -388,21 +388,21 @@ export const SearchAndReplace = Extension.create<SearchAndReplaceOptions, Search
getRegex(searchTerm, disableRegex, caseSensitive),
searchResultClass,
resultIndex
);
)
editor.storage.searchAndReplace.results = results;
editor.storage.searchAndReplace.results = results
return decorationsToReturn;
return decorationsToReturn
}
},
props: {
decorations(state) {
return this.getState(state);
return this.getState(state)
}
}
})
];
]
}
});
})
export default SearchAndReplace;
export default SearchAndReplace

View file

@ -1,12 +1,12 @@
import { type Attributes, Extension } from '@tiptap/core';
import '@tiptap/extension-text-style';
import { type Attributes, Extension } from '@tiptap/core'
import '@tiptap/extension-text-style'
declare module '@tiptap/core' {
interface Commands<ReturnType> {
fontSize: {
setFontSize: (size: string) => ReturnType;
unsetFontSize: () => ReturnType;
};
setFontSize: (size: string) => ReturnType
unsetFontSize: () => ReturnType
}
}
}
@ -16,7 +16,7 @@ export const FontSize = Extension.create({
addOptions() {
return {
types: ['textStyle']
};
}
},
addGlobalAttributes() {
@ -34,17 +34,17 @@ export const FontSize = Extension.create({
parseHTML: (element) => element.style.fontSize.replace(/['"]+/g, ''),
renderHTML: (attributes) => {
if (!attributes.fontSize) {
return {};
return {}
}
return {
style: `font-size: ${attributes.fontSize}`
};
}
}
}
} as Attributes
}
];
]
},
addCommands() {
@ -57,8 +57,8 @@ export const FontSize = Extension.create({
() =>
({ chain }) =>
chain().setMark('textStyle', { fontSize: null }).removeEmptyTextStyle().run()
};
}
});
}
})
export default FontSize;
export default FontSize

View file

@ -1,4 +1,4 @@
import { Extension, textInputRule } from '@tiptap/core';
import { Extension, textInputRule } from '@tiptap/core'
export const SmilieReplacer = Extension.create({
name: 'smilieReplacer',
@ -128,6 +128,6 @@ export const SmilieReplacer = Extension.create({
textInputRule({ find: /:@ $/, replace: '😠 ' }),
textInputRule({ find: /<3 $/, replace: '❤️ ' }),
textInputRule({ find: /\/shrug $/, replace: '¯\\_(ツ)_/¯' })
];
]
}
});
})

View file

@ -1,7 +1,7 @@
import { SvelteNodeViewRenderer } from 'svelte-tiptap';
import { Audio } from './AudioExtension.js';
import type { NodeViewProps } from '@tiptap/core';
import type { Component } from 'svelte';
import { SvelteNodeViewRenderer } from 'svelte-tiptap'
import { Audio } from './AudioExtension.js'
import type { NodeViewProps } from '@tiptap/core'
import type { Component } from 'svelte'
export const AudioExtended = (content: Component<NodeViewProps>) =>
Audio.extend({
@ -25,10 +25,10 @@ export const AudioExtended = (content: Component<NodeViewProps>) =>
align: {
default: 'left'
}
};
}
},
addNodeView: () => {
return SvelteNodeViewRenderer(content);
return SvelteNodeViewRenderer(content)
}
});
})

View file

@ -1,8 +1,8 @@
import { Node, nodeInputRule } from '@tiptap/core';
import { Plugin, PluginKey } from '@tiptap/pm/state';
import { Node, nodeInputRule } from '@tiptap/core'
import { Plugin, PluginKey } from '@tiptap/pm/state'
export interface AudioOptions {
HTMLAttributes: Record<string, unknown>;
HTMLAttributes: Record<string, unknown>
}
declare module '@tiptap/core' {
@ -11,20 +11,20 @@ declare module '@tiptap/core' {
/**
* Set a audio node
*/
setAudio: (src: string) => ReturnType;
setAudio: (src: string) => ReturnType
/**
* Toggle a audio
*/
toggleAudio: (src: string) => ReturnType;
toggleAudio: (src: string) => ReturnType
/**
* Remove a audio
*/
removeAudio: () => ReturnType;
};
removeAudio: () => ReturnType
}
}
}
const AUDIO_INPUT_REGEX = /!\[(.+|:?)]\((\S+)(?:(?:\s+)["'](\S+)["'])?\)/;
const AUDIO_INPUT_REGEX = /!\[(.+|:?)]\((\S+)(?:(?:\s+)["'](\S+)["'])?\)/
export const Audio = Node.create<AudioOptions>({
name: 'audio',
@ -35,7 +35,7 @@ export const Audio = Node.create<AudioOptions>({
addOptions() {
return {
HTMLAttributes: {}
};
}
},
addAttributes() {
return {
@ -44,7 +44,7 @@ export const Audio = Node.create<AudioOptions>({
parseHTML: (el) => (el as HTMLSpanElement).getAttribute('src'),
renderHTML: (attrs) => ({ src: attrs.src })
}
};
}
},
parseHTML() {
return [
@ -52,7 +52,7 @@ export const Audio = Node.create<AudioOptions>({
tag: 'audio',
getAttrs: (el) => ({ src: (el as HTMLAudioElement).getAttribute('src') })
}
];
]
},
renderHTML({ HTMLAttributes }) {
@ -60,7 +60,7 @@ export const Audio = Node.create<AudioOptions>({
'audio',
{ controls: 'true', style: 'width: 100%;', ...HTMLAttributes },
['source', HTMLAttributes]
];
]
},
addCommands() {
return {
@ -79,7 +79,7 @@ export const Audio = Node.create<AudioOptions>({
() =>
({ commands }) =>
commands.deleteNode(this.name)
};
}
},
addInputRules() {
return [
@ -87,12 +87,12 @@ export const Audio = Node.create<AudioOptions>({
find: AUDIO_INPUT_REGEX,
type: this.type,
getAttributes: (match) => {
const [, , src] = match;
const [, , src] = match
return { src };
return { src }
}
})
];
]
},
addProseMirrorPlugins() {
return [
@ -105,43 +105,43 @@ export const Audio = Node.create<AudioOptions>({
const {
state: { schema, tr },
dispatch
} = view;
} = view
const hasFiles =
event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length;
event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length
if (!hasFiles) return false;
if (!hasFiles) return false
const audios = Array.from(event.dataTransfer.files).filter((file) =>
/audio/i.test(file.type)
);
)
if (audios.length === 0) return false;
if (audios.length === 0) return false
event.preventDefault();
event.preventDefault()
const coordinates = view.posAtCoords({ left: event.clientX, top: event.clientY });
const coordinates = view.posAtCoords({ left: event.clientX, top: event.clientY })
audios.forEach((audio) => {
const reader = new FileReader();
const reader = new FileReader()
reader.onload = (readerEvent) => {
const node = schema.nodes.audio.create({ src: readerEvent.target?.result });
const node = schema.nodes.audio.create({ src: readerEvent.target?.result })
if (coordinates && typeof coordinates.pos === 'number') {
const transaction = tr.insert(coordinates?.pos, node);
const transaction = tr.insert(coordinates?.pos, node)
dispatch(transaction);
dispatch(transaction)
}
}
};
reader.readAsDataURL(audio);
});
reader.readAsDataURL(audio)
})
return true;
return true
}
}
}
})
];
]
}
});
})

View file

@ -1,15 +1,15 @@
import { Editor, Node, mergeAttributes, type CommandProps, type NodeViewProps } from '@tiptap/core';
import type { Component } from 'svelte';
import { SvelteNodeViewRenderer } from 'svelte-tiptap';
import { Editor, Node, mergeAttributes, type CommandProps, type NodeViewProps } from '@tiptap/core'
import type { Component } from 'svelte'
import { SvelteNodeViewRenderer } from 'svelte-tiptap'
export interface AudioPlaceholderOptions {
HTMLAttributes: Record<string, object>;
onDrop: (files: File[], editor: Editor) => void;
onDropRejected?: (files: File[], editor: Editor) => void;
onEmbed: (url: string, editor: Editor) => void;
allowedMimeTypes?: Record<string, string[]>;
maxFiles?: number;
maxSize?: number;
HTMLAttributes: Record<string, object>
onDrop: (files: File[], editor: Editor) => void
onDropRejected?: (files: File[], editor: Editor) => void
onEmbed: (url: string, editor: Editor) => void
allowedMimeTypes?: Record<string, string[]>
maxFiles?: number
maxSize?: number
}
declare module '@tiptap/core' {
@ -18,8 +18,8 @@ declare module '@tiptap/core' {
/**
* Inserts an audio placeholder
*/
insertAudioPlaceholder: () => ReturnType;
};
insertAudioPlaceholder: () => ReturnType
}
}
}
@ -34,14 +34,14 @@ export const AudioPlaceholder = (
onDrop: () => {},
onDropRejected: () => {},
onEmbed: () => {}
};
}
},
parseHTML() {
return [{ tag: `div[data-type="${this.name}"]` }];
return [{ tag: `div[data-type="${this.name}"]` }]
},
renderHTML({ HTMLAttributes }) {
return ['div', mergeAttributes(HTMLAttributes)];
return ['div', mergeAttributes(HTMLAttributes)]
},
group: 'block',
draggable: true,
@ -50,15 +50,15 @@ export const AudioPlaceholder = (
isolating: true,
addNodeView() {
return SvelteNodeViewRenderer(component);
return SvelteNodeViewRenderer(component)
},
addCommands() {
return {
insertAudioPlaceholder: () => (props: CommandProps) => {
return props.commands.insertContent({
type: 'audio-placeholder'
});
})
}
};
}
});
}
})

View file

@ -1,27 +1,27 @@
import { Slice } from '@tiptap/pm/model';
import { EditorView } from '@tiptap/pm/view';
import * as pmView from '@tiptap/pm/view';
import { Slice } from '@tiptap/pm/model'
import { EditorView } from '@tiptap/pm/view'
import * as pmView from '@tiptap/pm/view'
function getPmView() {
try {
return pmView;
return pmView
} catch (error: Error) {
return null;
return null
}
}
export function serializeForClipboard(view: EditorView, slice: Slice) {
// Newer Tiptap/ProseMirror
if (view && typeof view.serializeForClipboard === 'function') {
return view.serializeForClipboard(slice);
return view.serializeForClipboard(slice)
}
// Older version fallback
const proseMirrorView = getPmView();
const proseMirrorView = getPmView()
if (proseMirrorView && typeof proseMirrorView?.__serializeForClipboard === 'function') {
return proseMirrorView.__serializeForClipboard(view, slice);
return proseMirrorView.__serializeForClipboard(view, slice)
}
throw new Error('No supported clipboard serialization method found.');
throw new Error('No supported clipboard serialization method found.')
}

View file

@ -1,61 +1,61 @@
import { Extension } from '@tiptap/core';
import { NodeSelection, Plugin, PluginKey, TextSelection } from '@tiptap/pm/state';
import { Fragment, Slice, Node } from '@tiptap/pm/model';
import { EditorView } from '@tiptap/pm/view';
import { serializeForClipboard } from './ClipboardSerializer.js';
import { Extension } from '@tiptap/core'
import { NodeSelection, Plugin, PluginKey, TextSelection } from '@tiptap/pm/state'
import { Fragment, Slice, Node } from '@tiptap/pm/model'
import { EditorView } from '@tiptap/pm/view'
import { serializeForClipboard } from './ClipboardSerializer.js'
export interface GlobalDragHandleOptions {
/**
* The width of the drag handle
*/
dragHandleWidth: number;
dragHandleWidth: number
/**
* The treshold for scrolling
*/
scrollTreshold: number;
scrollTreshold: number
/*
* The css selector to query for the drag handle. (eg: '.custom-handle').
* If handle element is found, that element will be used as drag handle. If not, a default handle will be created
*/
dragHandleSelector?: string;
dragHandleSelector?: string
/**
* Tags to be excluded for drag handle
*/
excludedTags: string[];
excludedTags: string[]
/**
* Custom nodes to be included for drag handle
*/
customNodes: string[];
customNodes: string[]
/**
* onNodeChange callback for drag handle
* @param data
* @returns
*/
onMouseMove?: (data: { node: Node; pos: number }) => void;
onMouseMove?: (data: { node: Node; pos: number }) => void
}
function absoluteRect(node: Element) {
const data = node.getBoundingClientRect();
const modal = node.closest('[role="dialog"]');
const data = node.getBoundingClientRect()
const modal = node.closest('[role="dialog"]')
if (modal && window.getComputedStyle(modal).transform !== 'none') {
const modalRect = modal.getBoundingClientRect();
const modalRect = modal.getBoundingClientRect()
return {
top: data.top - modalRect.top,
left: data.left - modalRect.left,
width: data.width
};
}
}
return {
top: data.top,
left: data.left,
width: data.width
};
}
}
function nodeDOMAtCoords(coords: { x: number; y: number }, options: GlobalDragHandleOptions) {
@ -71,34 +71,34 @@ function nodeDOMAtCoords(coords: { x: number; y: number }, options: GlobalDragHa
'h5',
'h6',
...options.customNodes.map((node) => `[data-type=${node}]`)
].join(', ');
].join(', ')
return document
.elementsFromPoint(coords.x, coords.y)
.find(
(elem: Element) => elem.parentElement?.matches?.('.ProseMirror') || elem.matches(selectors)
);
)
}
function nodePosAtDOM(node: Element, view: EditorView, options: GlobalDragHandleOptions) {
const boundingRect = node.getBoundingClientRect();
const boundingRect = node.getBoundingClientRect()
return view.posAtCoords({
left: boundingRect.left + 50 + options.dragHandleWidth,
top: boundingRect.top + 1
})?.inside;
})?.inside
}
function calcNodePos(pos: number, view: EditorView) {
const $pos = view.state.doc.resolve(pos);
if ($pos.depth > 1) return $pos.before($pos.depth);
return pos;
const $pos = view.state.doc.resolve(pos)
if ($pos.depth > 1) return $pos.before($pos.depth)
return pos
}
export function DragHandlePlugin(options: GlobalDragHandleOptions & { pluginKey: string }) {
let listType = '';
let listType = ''
function handleDragStart(event: DragEvent, view: EditorView) {
view.focus();
view.focus()
if (!event.dataTransfer) return;
if (!event.dataTransfer) return
const node = nodeDOMAtCoords(
{
@ -106,38 +106,38 @@ export function DragHandlePlugin(options: GlobalDragHandleOptions & { pluginKey:
y: event.clientY
},
options
);
)
if (!(node instanceof Element)) return;
if (!(node instanceof Element)) return
let draggedNodePos = nodePosAtDOM(node, view, options);
if (draggedNodePos == null || draggedNodePos < 0) return;
draggedNodePos = calcNodePos(draggedNodePos, view);
let draggedNodePos = nodePosAtDOM(node, view, options)
if (draggedNodePos == null || draggedNodePos < 0) return
draggedNodePos = calcNodePos(draggedNodePos, view)
const { from, to } = view.state.selection;
const diff = from - to;
const { from, to } = view.state.selection
const diff = from - to
const fromSelectionPos = calcNodePos(from, view);
let differentNodeSelected = false;
const fromSelectionPos = calcNodePos(from, view)
let differentNodeSelected = false
const nodePos = view.state.doc.resolve(fromSelectionPos);
const nodePos = view.state.doc.resolve(fromSelectionPos)
// Check if nodePos points to the top level node
if (nodePos.node().type.name === 'doc') differentNodeSelected = true;
if (nodePos.node().type.name === 'doc') differentNodeSelected = true
else {
const nodeSelection = NodeSelection.create(view.state.doc, nodePos.before());
const nodeSelection = NodeSelection.create(view.state.doc, nodePos.before())
// Check if the node where the drag event started is part of the current selection
differentNodeSelected = !(
draggedNodePos + 1 >= nodeSelection.$from.pos && draggedNodePos <= nodeSelection.$to.pos
);
)
}
let selection = view.state.selection;
let selection = view.state.selection
if (!differentNodeSelected && diff !== 0 && !(view.state.selection instanceof NodeSelection)) {
const endSelection = NodeSelection.create(view.state.doc, to - 1);
selection = TextSelection.create(view.state.doc, draggedNodePos, endSelection.$to.pos);
const endSelection = NodeSelection.create(view.state.doc, to - 1)
selection = TextSelection.create(view.state.doc, draggedNodePos, endSelection.$to.pos)
} else {
selection = NodeSelection.create(view.state.doc, draggedNodePos);
selection = NodeSelection.create(view.state.doc, draggedNodePos)
// if inline node is selected, e.g mention -> go to the parent node to select the whole node
// if table row is selected, go to the parent node to select the whole node
@ -145,58 +145,58 @@ export function DragHandlePlugin(options: GlobalDragHandleOptions & { pluginKey:
(selection as NodeSelection).node.type.isInline ||
(selection as NodeSelection).node.type.name === 'tableRow'
) {
const $pos = view.state.doc.resolve(selection.from);
selection = NodeSelection.create(view.state.doc, $pos.before());
const $pos = view.state.doc.resolve(selection.from)
selection = NodeSelection.create(view.state.doc, $pos.before())
}
}
view.dispatch(view.state.tr.setSelection(selection));
view.dispatch(view.state.tr.setSelection(selection))
// If the selected node is a list item, we need to save the type of the wrapping list e.g. OL or UL
if (
view.state.selection instanceof NodeSelection &&
view.state.selection.node.type.name === 'listItem'
) {
listType = node.parentElement!.tagName;
listType = node.parentElement!.tagName
}
const slice = view.state.selection.content();
const { dom, text } = serializeForClipboard(view, slice);
const slice = view.state.selection.content()
const { dom, text } = serializeForClipboard(view, slice)
event.dataTransfer.clearData();
event.dataTransfer.setData('text/html', dom.innerHTML);
event.dataTransfer.setData('text/plain', text);
event.dataTransfer.effectAllowed = 'copyMove';
event.dataTransfer.clearData()
event.dataTransfer.setData('text/html', dom.innerHTML)
event.dataTransfer.setData('text/plain', text)
event.dataTransfer.effectAllowed = 'copyMove'
event.dataTransfer.setDragImage(node, 0, 0);
event.dataTransfer.setDragImage(node, 0, 0)
view.dragging = { slice, move: event.ctrlKey };
view.dragging = { slice, move: event.ctrlKey }
}
let dragHandleElement: HTMLElement | null = null;
let dragHandleElement: HTMLElement | null = null
function hideDragHandle() {
if (dragHandleElement) {
dragHandleElement.classList.add('hide');
dragHandleElement.classList.add('hide')
}
}
function showDragHandle() {
if (dragHandleElement) {
dragHandleElement.classList.remove('hide');
dragHandleElement.classList.remove('hide')
}
}
function hideHandleOnEditorOut(event: MouseEvent) {
if (event.target instanceof Element) {
// Check if the relatedTarget class is still inside the editor
const relatedTarget = event.relatedTarget as HTMLElement;
const relatedTarget = event.relatedTarget as HTMLElement
const isInsideEditor =
relatedTarget?.classList.contains('tiptap') ||
relatedTarget?.classList.contains('drag-handle');
relatedTarget?.classList.contains('drag-handle')
if (isInsideEditor) return;
if (isInsideEditor) return
}
hideDragHandle();
hideDragHandle()
}
return new Plugin({
@ -204,54 +204,54 @@ export function DragHandlePlugin(options: GlobalDragHandleOptions & { pluginKey:
view: (view) => {
const handleBySelector = options.dragHandleSelector
? document.querySelector<HTMLElement>(options.dragHandleSelector)
: null;
dragHandleElement = handleBySelector ?? document.createElement('div');
dragHandleElement.draggable = true;
dragHandleElement.dataset.dragHandle = '';
dragHandleElement.classList.add('drag-handle');
: null
dragHandleElement = handleBySelector ?? document.createElement('div')
dragHandleElement.draggable = true
dragHandleElement.dataset.dragHandle = ''
dragHandleElement.classList.add('drag-handle')
function onDragHandleDragStart(e: DragEvent) {
handleDragStart(e, view);
handleDragStart(e, view)
}
dragHandleElement.addEventListener('dragstart', onDragHandleDragStart);
dragHandleElement.addEventListener('dragstart', onDragHandleDragStart)
function onDragHandleDrag(e: DragEvent) {
hideDragHandle();
const scrollY = window.scrollY;
hideDragHandle()
const scrollY = window.scrollY
if (e.clientY < options.scrollTreshold) {
window.scrollTo({ top: scrollY - 30, behavior: 'smooth' });
window.scrollTo({ top: scrollY - 30, behavior: 'smooth' })
} else if (window.innerHeight - e.clientY < options.scrollTreshold) {
window.scrollTo({ top: scrollY + 30, behavior: 'smooth' });
window.scrollTo({ top: scrollY + 30, behavior: 'smooth' })
}
}
dragHandleElement.addEventListener('drag', onDragHandleDrag);
dragHandleElement.addEventListener('drag', onDragHandleDrag)
hideDragHandle();
hideDragHandle()
if (!handleBySelector) {
view?.dom?.parentElement?.appendChild(dragHandleElement);
view?.dom?.parentElement?.appendChild(dragHandleElement)
}
view?.dom?.parentElement?.addEventListener('mouseout', hideHandleOnEditorOut);
view?.dom?.parentElement?.addEventListener('mouseout', hideHandleOnEditorOut)
return {
destroy: () => {
if (!handleBySelector) {
dragHandleElement?.remove?.();
dragHandleElement?.remove?.()
}
dragHandleElement?.removeEventListener('drag', onDragHandleDrag)
dragHandleElement?.removeEventListener('dragstart', onDragHandleDragStart)
dragHandleElement = null
view?.dom?.parentElement?.removeEventListener('mouseout', hideHandleOnEditorOut)
}
dragHandleElement?.removeEventListener('drag', onDragHandleDrag);
dragHandleElement?.removeEventListener('dragstart', onDragHandleDragStart);
dragHandleElement = null;
view?.dom?.parentElement?.removeEventListener('mouseout', hideHandleOnEditorOut);
}
};
},
props: {
handleDOMEvents: {
mousemove: (view, event) => {
if (!view.editable) {
return;
return
}
const node = nodeDOMAtCoords(
@ -260,76 +260,76 @@ export function DragHandlePlugin(options: GlobalDragHandleOptions & { pluginKey:
y: event.clientY
},
options
);
)
const notDragging = node?.closest('.not-draggable');
const excludedTagList = options.excludedTags.concat(['ol', 'ul']).join(', ');
const notDragging = node?.closest('.not-draggable')
const excludedTagList = options.excludedTags.concat(['ol', 'ul']).join(', ')
if (!(node instanceof Element) || node.matches(excludedTagList) || notDragging) {
hideDragHandle();
return;
hideDragHandle()
return
}
const nodePos = nodePosAtDOM(node, view, options);
const nodePos = nodePosAtDOM(node, view, options)
if (nodePos !== undefined) {
const currentNode = view.state.doc.nodeAt(nodePos);
const currentNode = view.state.doc.nodeAt(nodePos)
if (currentNode !== null) {
options.onMouseMove?.({ node: currentNode, pos: nodePos });
options.onMouseMove?.({ node: currentNode, pos: nodePos })
}
}
const compStyle = window.getComputedStyle(node);
const parsedLineHeight = parseInt(compStyle.lineHeight, 10);
const compStyle = window.getComputedStyle(node)
const parsedLineHeight = parseInt(compStyle.lineHeight, 10)
const lineHeight = isNaN(parsedLineHeight)
? parseInt(compStyle.fontSize) * 1.2
: parsedLineHeight;
const paddingTop = parseInt(compStyle.paddingTop, 10);
: parsedLineHeight
const paddingTop = parseInt(compStyle.paddingTop, 10)
const rect = absoluteRect(node);
const rect = absoluteRect(node)
rect.top += (lineHeight - 24) / 2;
rect.top += paddingTop;
rect.top += (lineHeight - 24) / 2
rect.top += paddingTop
// Li markers
if (node.matches('ul:not([data-type=taskList]) li, ol li')) {
rect.left -= options.dragHandleWidth;
rect.left -= options.dragHandleWidth
}
rect.width = options.dragHandleWidth;
rect.width = options.dragHandleWidth
if (!dragHandleElement) return;
if (!dragHandleElement) return
dragHandleElement.style.left = `${rect.left - rect.width}px`;
dragHandleElement.style.top = `${rect.top}px`;
showDragHandle();
dragHandleElement.style.left = `${rect.left - rect.width}px`
dragHandleElement.style.top = `${rect.top}px`
showDragHandle()
},
keydown: () => {
hideDragHandle();
hideDragHandle()
},
mousewheel: () => {
hideDragHandle();
hideDragHandle()
},
// dragging class is used for CSS
dragstart: (view) => {
view.dom.classList.add('dragging');
view.dom.classList.add('dragging')
},
drop: (view, event) => {
view.dom.classList.remove('dragging');
hideDragHandle();
let droppedNode: Node | null = null;
view.dom.classList.remove('dragging')
hideDragHandle()
let droppedNode: Node | null = null
const dropPos = view.posAtCoords({
left: event.clientX,
top: event.clientY
});
})
if (!dropPos) return;
if (!dropPos) return
if (view.state.selection instanceof NodeSelection) {
droppedNode = view.state.selection.node;
droppedNode = view.state.selection.node
}
if (!droppedNode) return;
if (!droppedNode) return
const resolvedPos = view.state.doc.resolve(dropPos.pos);
const resolvedPos = view.state.doc.resolve(dropPos.pos)
const isDroppedInsideList = resolvedPos.parent.type.name === 'listItem';
const isDroppedInsideList = resolvedPos.parent.type.name === 'listItem'
// If the selected node is a list item and is not dropped inside a list, we need to wrap it inside <ol> tag otherwise ol list items will be transformed into ul list item when dropped
if (
@ -338,17 +338,17 @@ export function DragHandlePlugin(options: GlobalDragHandleOptions & { pluginKey:
!isDroppedInsideList &&
listType == 'OL'
) {
const newList = view.state.schema.nodes.orderedList?.createAndFill(null, droppedNode);
const slice = new Slice(Fragment.from(newList), 0, 0);
view.dragging = { slice, move: event.ctrlKey };
const newList = view.state.schema.nodes.orderedList?.createAndFill(null, droppedNode)
const slice = new Slice(Fragment.from(newList), 0, 0)
view.dragging = { slice, move: event.ctrlKey }
}
},
dragend: (view) => {
view.dom.classList.remove('dragging');
view.dom.classList.remove('dragging')
}
}
}
});
})
}
const GlobalDragHandle = Extension.create({
@ -360,7 +360,7 @@ const GlobalDragHandle = Extension.create({
scrollTreshold: 100,
excludedTags: [],
customNodes: []
};
}
},
addProseMirrorPlugins() {
@ -374,8 +374,8 @@ const GlobalDragHandle = Extension.create({
customNodes: this.options.customNodes,
onMouseMove: this.options.onMouseMove
})
];
]
}
});
})
export default GlobalDragHandle;
export default GlobalDragHandle

View file

@ -1,10 +1,10 @@
import { Node } from '@tiptap/core';
import { Node } from '@tiptap/core'
export interface IframeOptions {
allowFullscreen: boolean;
allowFullscreen: boolean
HTMLAttributes: {
[key: string]: unknown;
};
[key: string]: unknown
}
}
declare module '@tiptap/core' {
@ -13,9 +13,9 @@ declare module '@tiptap/core' {
/**
* Add an iframe with src
*/
setIframe: (options: { src: string }) => ReturnType;
removeIframe: () => ReturnType;
};
setIframe: (options: { src: string }) => ReturnType
removeIframe: () => ReturnType
}
}
}
@ -32,7 +32,7 @@ export default Node.create<IframeOptions>({
HTMLAttributes: {
class: 'iframe-wrapper'
}
};
}
},
addAttributes() {
@ -47,7 +47,7 @@ export default Node.create<IframeOptions>({
default: this.options.allowFullscreen,
parseHTML: () => this.options.allowFullscreen
}
};
}
},
parseHTML() {
@ -55,11 +55,11 @@ export default Node.create<IframeOptions>({
{
tag: 'iframe'
}
];
]
},
renderHTML({ HTMLAttributes }) {
return ['div', this.options.HTMLAttributes, ['iframe', HTMLAttributes]];
return ['div', this.options.HTMLAttributes, ['iframe', HTMLAttributes]]
},
addCommands() {
@ -67,19 +67,19 @@ export default Node.create<IframeOptions>({
setIframe:
(options: { src: string }) =>
({ tr, dispatch }) => {
const { selection } = tr;
const node = this.type.create(options);
const { selection } = tr
const node = this.type.create(options)
if (dispatch) {
tr.replaceRangeWith(selection.from, selection.to, node);
tr.replaceRangeWith(selection.from, selection.to, node)
}
return true;
return true
},
removeIframe:
() =>
({ commands }) =>
commands.deleteNode(this.name)
};
}
});
}
})

View file

@ -1,8 +1,8 @@
import { SvelteNodeViewRenderer } from 'svelte-tiptap';
import { SvelteNodeViewRenderer } from 'svelte-tiptap'
import type { NodeViewProps } from '@tiptap/core';
import type { Component } from 'svelte';
import IFrame from './IFrame.js';
import type { NodeViewProps } from '@tiptap/core'
import type { Component } from 'svelte'
import IFrame from './IFrame.js'
export const IFrameExtended = (content: Component<NodeViewProps>) =>
IFrame.extend({
@ -26,10 +26,10 @@ export const IFrameExtended = (content: Component<NodeViewProps>) =>
align: {
default: 'left'
}
};
}
},
addNodeView: () => {
return SvelteNodeViewRenderer(content);
return SvelteNodeViewRenderer(content)
}
});
})

View file

@ -1,9 +1,9 @@
import { Node, mergeAttributes, type CommandProps, type NodeViewProps } from '@tiptap/core';
import type { Component } from 'svelte';
import { SvelteNodeViewRenderer } from 'svelte-tiptap';
import { Node, mergeAttributes, type CommandProps, type NodeViewProps } from '@tiptap/core'
import type { Component } from 'svelte'
import { SvelteNodeViewRenderer } from 'svelte-tiptap'
export interface IFramePlaceholderOptions {
HTMLAttributes: Record<string, object>;
HTMLAttributes: Record<string, object>
}
declare module '@tiptap/core' {
@ -12,8 +12,8 @@ declare module '@tiptap/core' {
/**
* Inserts a IFrame placeholder
*/
insertIFramePlaceholder: () => ReturnType;
};
insertIFramePlaceholder: () => ReturnType
}
}
}
@ -26,14 +26,14 @@ export const IFramePlaceholder = (content: Component<NodeViewProps>) =>
onDrop: () => {},
onDropRejected: () => {},
onEmbed: () => {}
};
}
},
parseHTML() {
return [{ tag: `div[data-type="${this.name}"]` }];
return [{ tag: `div[data-type="${this.name}"]` }]
},
renderHTML({ HTMLAttributes }) {
return ['div', mergeAttributes(HTMLAttributes)];
return ['div', mergeAttributes(HTMLAttributes)]
},
group: 'block',
draggable: true,
@ -42,15 +42,15 @@ export const IFramePlaceholder = (content: Component<NodeViewProps>) =>
isolating: true,
addNodeView() {
return SvelteNodeViewRenderer(content);
return SvelteNodeViewRenderer(content)
},
addCommands() {
return {
insertIFramePlaceholder: () => (props: CommandProps) => {
return props.commands.insertContent({
type: 'iframe-placeholder'
});
})
}
};
}
});
}
})

View file

@ -1,7 +1,7 @@
import { SvelteNodeViewRenderer } from 'svelte-tiptap';
import Image, { type ImageOptions } from '@tiptap/extension-image';
import type { Component } from 'svelte';
import type { NodeViewProps, Node } from '@tiptap/core';
import { SvelteNodeViewRenderer } from 'svelte-tiptap'
import Image, { type ImageOptions } from '@tiptap/extension-image'
import type { Component } from 'svelte'
import type { NodeViewProps, Node } from '@tiptap/core'
export const ImageExtended = (component: Component<NodeViewProps>): Node<ImageOptions, unknown> => {
return Image.extend({
@ -25,12 +25,12 @@ export const ImageExtended = (component: Component<NodeViewProps>): Node<ImageOp
align: {
default: 'left'
}
};
}
},
addNodeView: () => {
return SvelteNodeViewRenderer(component);
return SvelteNodeViewRenderer(component)
}
}).configure({
allowBase64: true
});
};
})
}

View file

@ -1,15 +1,15 @@
import { Editor, Node, mergeAttributes, type CommandProps, type NodeViewProps } from '@tiptap/core';
import type { Component } from 'svelte';
import { SvelteNodeViewRenderer } from 'svelte-tiptap';
import { Editor, Node, mergeAttributes, type CommandProps, type NodeViewProps } from '@tiptap/core'
import type { Component } from 'svelte'
import { SvelteNodeViewRenderer } from 'svelte-tiptap'
export interface ImagePlaceholderOptions {
HTMLAttributes: Record<string, object>;
onDrop: (files: File[], editor: Editor) => void;
onDropRejected?: (files: File[], editor: Editor) => void;
onEmbed: (url: string, editor: Editor) => void;
allowedMimeTypes?: Record<string, string[]>;
maxFiles?: number;
maxSize?: number;
HTMLAttributes: Record<string, object>
onDrop: (files: File[], editor: Editor) => void
onDropRejected?: (files: File[], editor: Editor) => void
onEmbed: (url: string, editor: Editor) => void
allowedMimeTypes?: Record<string, string[]>
maxFiles?: number
maxSize?: number
}
declare module '@tiptap/core' {
@ -18,8 +18,8 @@ declare module '@tiptap/core' {
/**
* Inserts an image placeholder
*/
insertImagePlaceholder: () => ReturnType;
};
insertImagePlaceholder: () => ReturnType
}
}
}
@ -34,14 +34,14 @@ export const ImagePlaceholder = (
onDrop: () => {},
onDropRejected: () => {},
onEmbed: () => {}
};
}
},
parseHTML() {
return [{ tag: `div[data-type="${this.name}"]` }];
return [{ tag: `div[data-type="${this.name}"]` }]
},
renderHTML({ HTMLAttributes }) {
return ['div', mergeAttributes(HTMLAttributes)];
return ['div', mergeAttributes(HTMLAttributes)]
},
group: 'block',
draggable: true,
@ -50,15 +50,15 @@ export const ImagePlaceholder = (
isolating: true,
addNodeView() {
return SvelteNodeViewRenderer(component);
return SvelteNodeViewRenderer(component)
},
addCommands() {
return {
insertImagePlaceholder: () => (props: CommandProps) => {
return props.commands.insertContent({
type: 'image-placeholder'
});
})
}
};
}
});
}
})

View file

@ -1,12 +1,12 @@
import { commands } from '../../commands/commands.js';
import { commands } from '../../commands/commands.js'
import type { EdraCommand } from '../../commands/types.js';
import type { Editor } from '@tiptap/core';
import type { EdraCommand } from '../../commands/types.js'
import type { Editor } from '@tiptap/core'
export interface Group {
name: string;
title: string;
commands: EdraCommand[];
name: string
title: string
commands: EdraCommand[]
}
export const GROUPS: Group[] = [
@ -20,7 +20,7 @@ export const GROUPS: Group[] = [
name: 'blockquote',
label: 'Blockquote',
action: (editor: Editor) => {
editor.chain().focus().setBlockquote().run();
editor.chain().focus().setBlockquote().run()
}
},
{
@ -28,7 +28,7 @@ export const GROUPS: Group[] = [
name: 'codeBlock',
label: 'Code Block',
action: (editor: Editor) => {
editor.chain().focus().setCodeBlock().run();
editor.chain().focus().setCodeBlock().run()
}
},
...commands.lists.commands
@ -45,11 +45,11 @@ export const GROUPS: Group[] = [
name: 'horizontalRule',
label: 'Horizontal Rule',
action: (editor: Editor) => {
editor.chain().focus().setHorizontalRule().run();
editor.chain().focus().setHorizontalRule().run()
}
}
]
}
];
]
export default GROUPS;
export default GROUPS

View file

@ -1,16 +1,16 @@
import { Editor, Extension } from '@tiptap/core';
import Suggestion, { type SuggestionProps, type SuggestionKeyDownProps } from '@tiptap/suggestion';
import { PluginKey } from '@tiptap/pm/state';
import { Editor, Extension } from '@tiptap/core'
import Suggestion, { type SuggestionProps, type SuggestionKeyDownProps } from '@tiptap/suggestion'
import { PluginKey } from '@tiptap/pm/state'
import { GROUPS } from './groups.js';
import SvelteRenderer from '../../svelte-renderer.js';
import tippy from 'tippy.js';
import type { Component } from 'svelte';
import { GROUPS } from './groups.js'
import SvelteRenderer from '../../svelte-renderer.js'
import tippy from 'tippy.js'
import type { Component } from 'svelte'
const extensionName = 'slashCommand';
const extensionName = 'slashCommand'
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let popup: any;
let popup: any
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export default (menuList: Component<any, any, ''>): Extension =>
@ -36,7 +36,7 @@ export default (menuList: Component<any, any, ''>): Extension =>
}
]
}
});
})
},
addProseMirrorPlugins() {
@ -47,53 +47,53 @@ export default (menuList: Component<any, any, ''>): Extension =>
allowSpaces: true,
pluginKey: new PluginKey(extensionName),
allow: ({ state, range }) => {
const $from = state.doc.resolve(range.from);
const $from = state.doc.resolve(range.from)
const afterContent = $from.parent.textContent?.substring(
$from.parent.textContent?.indexOf('/')
);
const isValidAfterContent = !afterContent?.endsWith(' ');
)
const isValidAfterContent = !afterContent?.endsWith(' ')
return isValidAfterContent;
return isValidAfterContent
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
command: ({ editor, props }: { editor: Editor; props: any }) => {
const { view, state } = editor;
const { $head, $from } = view.state.selection;
const { view, state } = editor
const { $head, $from } = view.state.selection
try {
const end = $from.pos;
const end = $from.pos
const from = $head?.nodeBefore
? end -
($head.nodeBefore.text?.substring($head.nodeBefore.text?.indexOf('/')).length ??
0)
: $from.start();
: $from.start()
const tr = state.tr.deleteRange(from, end);
view.dispatch(tr);
const tr = state.tr.deleteRange(from, end)
view.dispatch(tr)
} catch (error) {
console.error(error);
console.error(error)
}
props.action(editor);
view.focus();
props.action(editor)
view.focus()
},
items: ({ query }: { query: string }) => {
const withFilteredCommands = GROUPS.map((group) => ({
...group,
commands: group.commands.filter((item) => {
const labelNormalized = item.label.toLowerCase().trim();
const queryNormalized = query.toLowerCase().trim();
return labelNormalized.includes(queryNormalized);
const labelNormalized = item.label.toLowerCase().trim()
const queryNormalized = query.toLowerCase().trim()
return labelNormalized.includes(queryNormalized)
})
}));
}))
const withoutEmptyGroups = withFilteredCommands.filter((group) => {
if (group.commands.length > 0) {
return true;
return true
}
return false;
});
return false
})
const withEnabledSettings = withoutEmptyGroups.map((group) => ({
...group,
@ -101,98 +101,96 @@ export default (menuList: Component<any, any, ''>): Extension =>
...command,
isEnabled: true
}))
}));
}))
return withEnabledSettings;
return withEnabledSettings
},
render: () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let component: any;
let component: any
let scrollHandler: (() => void) | null = null;
let scrollHandler: (() => void) | null = null
return {
onStart: (props: SuggestionProps) => {
component = new SvelteRenderer(menuList, {
props,
editor: props.editor
});
})
const { view } = props.editor;
const { view } = props.editor
const getReferenceClientRect = () => {
if (!props.clientRect) {
return props.editor.storage[extensionName].rect;
return props.editor.storage[extensionName].rect
}
const rect = props.clientRect();
const rect = props.clientRect()
if (!rect) {
return props.editor.storage[extensionName].rect;
return props.editor.storage[extensionName].rect
}
let yPos = rect.y;
let yPos = rect.y
if (rect.top + component.element.offsetHeight + 40 > window.innerHeight) {
const diff =
rect.top + component.element.offsetHeight - window.innerHeight + 40;
yPos = rect.y - diff;
const diff = rect.top + component.element.offsetHeight - window.innerHeight + 40
yPos = rect.y - diff
}
return new DOMRect(rect.x, yPos, rect.width, rect.height);
};
return new DOMRect(rect.x, yPos, rect.width, rect.height)
}
scrollHandler = () => {
popup?.[0].setProps({
getReferenceClientRect
});
};
})
}
view.dom.parentElement?.addEventListener('scroll', scrollHandler);
view.dom.parentElement?.addEventListener('scroll', scrollHandler)
popup?.[0].setProps({
getReferenceClientRect,
appendTo: () => document.body,
content: component.element
});
})
popup?.[0].show();
popup?.[0].show()
},
onUpdate(props: SuggestionProps) {
component.updateProps(props);
component.updateProps(props)
const { view } = props.editor;
const { view } = props.editor
const getReferenceClientRect = () => {
if (!props.clientRect) {
return props.editor.storage[extensionName].rect;
return props.editor.storage[extensionName].rect
}
const rect = props.clientRect();
const rect = props.clientRect()
if (!rect) {
return props.editor.storage[extensionName].rect;
return props.editor.storage[extensionName].rect
}
let yPos = rect.y;
let yPos = rect.y
if (rect.top + component.element.offsetHeight + 40 > window.innerHeight) {
const diff =
rect.top + component.element.offsetHeight - window.innerHeight + 40;
yPos = rect.y - diff;
const diff = rect.top + component.element.offsetHeight - window.innerHeight + 40
yPos = rect.y - diff
}
return new DOMRect(rect.x, yPos, rect.width, rect.height);
};
return new DOMRect(rect.x, yPos, rect.width, rect.height)
}
const scrollHandler = () => {
popup?.[0].setProps({
getReferenceClientRect
});
};
})
}
view.dom.parentElement?.addEventListener('scroll', scrollHandler);
view.dom.parentElement?.addEventListener('scroll', scrollHandler)
props.editor.storage[extensionName].rect = props.clientRect
? getReferenceClientRect()
@ -203,40 +201,40 @@ export default (menuList: Component<any, any, ''>): Extension =>
top: 0,
right: 0,
bottom: 0
};
}
popup?.[0].setProps({
getReferenceClientRect
});
})
},
onKeyDown(props: SuggestionKeyDownProps) {
if (props.event.key === 'Escape') {
popup?.[0].hide();
return true;
popup?.[0].hide()
return true
}
if (!popup?.[0].state.isShown) {
popup?.[0].show();
popup?.[0].show()
}
if (props.event.key === 'Enter') return true;
if (props.event.key === 'Enter') return true
// return component.ref?.onKeyDown(props);
return false;
return false
},
onExit(props) {
popup?.[0].hide();
popup?.[0].hide()
if (scrollHandler) {
const { view } = props.editor;
view.dom.parentElement?.removeEventListener('scroll', scrollHandler);
const { view } = props.editor
view.dom.parentElement?.removeEventListener('scroll', scrollHandler)
}
component.destroy()
}
component.destroy();
}
};
}
})
];
]
},
addStorage() {
@ -249,6 +247,6 @@ export default (menuList: Component<any, any, ''>): Extension =>
right: 0,
bottom: 0
}
};
}
});
}
})

View file

@ -1,4 +1,4 @@
export { Table } from './table.js';
export { TableCell } from './table-cell.js';
export { TableRow } from './table-row.js';
export { TableHeader } from './table-header.js';
export { Table } from './table.js'
export { TableCell } from './table-cell.js'
export { TableRow } from './table-row.js'
export { TableHeader } from './table-header.js'

View file

@ -1,11 +1,11 @@
import { mergeAttributes, Node } from '@tiptap/core';
import { Plugin } from '@tiptap/pm/state';
import { Decoration, DecorationSet } from '@tiptap/pm/view';
import { mergeAttributes, Node } from '@tiptap/core'
import { Plugin } from '@tiptap/pm/state'
import { Decoration, DecorationSet } from '@tiptap/pm/view'
import { getCellsInColumn, isRowSelected, selectRow } from './utils.js';
import { getCellsInColumn, isRowSelected, selectRow } from './utils.js'
export interface TableCellOptions {
HTMLAttributes: Record<string, unknown>;
HTMLAttributes: Record<string, unknown>
}
export const TableCell = Node.create<TableCellOptions>({
@ -19,15 +19,15 @@ export const TableCell = Node.create<TableCellOptions>({
addOptions() {
return {
HTMLAttributes: {}
};
}
},
parseHTML() {
return [{ tag: 'td' }];
return [{ tag: 'td' }]
},
renderHTML({ HTMLAttributes }) {
return ['td', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0];
return ['td', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]
},
addAttributes() {
@ -35,90 +35,90 @@ export const TableCell = Node.create<TableCellOptions>({
colspan: {
default: 1,
parseHTML: (element) => {
const colspan = element.getAttribute('colspan');
const value = colspan ? parseInt(colspan, 10) : 1;
const colspan = element.getAttribute('colspan')
const value = colspan ? parseInt(colspan, 10) : 1
return value;
return value
}
},
rowspan: {
default: 1,
parseHTML: (element) => {
const rowspan = element.getAttribute('rowspan');
const value = rowspan ? parseInt(rowspan, 10) : 1;
const rowspan = element.getAttribute('rowspan')
const value = rowspan ? parseInt(rowspan, 10) : 1
return value;
return value
}
},
colwidth: {
default: null,
parseHTML: (element) => {
const colwidth = element.getAttribute('colwidth');
const value = colwidth ? [parseInt(colwidth, 10)] : null;
const colwidth = element.getAttribute('colwidth')
const value = colwidth ? [parseInt(colwidth, 10)] : null
return value;
return value
}
},
style: {
default: null
}
};
}
},
addProseMirrorPlugins() {
const { isEditable } = this.editor;
const { isEditable } = this.editor
return [
new Plugin({
props: {
decorations: (state) => {
if (!isEditable) {
return DecorationSet.empty;
return DecorationSet.empty
}
const { doc, selection } = state;
const decorations: Decoration[] = [];
const cells = getCellsInColumn(0)(selection);
const { doc, selection } = state
const decorations: Decoration[] = []
const cells = getCellsInColumn(0)(selection)
if (cells) {
cells.forEach(({ pos }: { pos: number }, index: number) => {
decorations.push(
Decoration.widget(pos + 1, () => {
const rowSelected = isRowSelected(index)(selection);
let className = 'grip-row';
const rowSelected = isRowSelected(index)(selection)
let className = 'grip-row'
if (rowSelected) {
className += ' selected';
className += ' selected'
}
if (index === 0) {
className += ' first';
className += ' first'
}
if (index === cells.length - 1) {
className += ' last';
className += ' last'
}
const grip = document.createElement('a');
const grip = document.createElement('a')
grip.className = className;
grip.className = className
grip.addEventListener('mousedown', (event) => {
event.preventDefault();
event.stopImmediatePropagation();
event.preventDefault()
event.stopImmediatePropagation()
this.editor.view.dispatch(selectRow(index)(this.editor.state.tr));
});
return grip;
this.editor.view.dispatch(selectRow(index)(this.editor.state.tr))
})
return grip
})
)
})
);
});
}
return DecorationSet.create(doc, decorations);
return DecorationSet.create(doc, decorations)
}
}
})
];
]
}
});
})

View file

@ -1,8 +1,8 @@
import TiptapTableHeader from '@tiptap/extension-table-header';
import { Plugin } from '@tiptap/pm/state';
import { Decoration, DecorationSet } from '@tiptap/pm/view';
import TiptapTableHeader from '@tiptap/extension-table-header'
import { Plugin } from '@tiptap/pm/state'
import { Decoration, DecorationSet } from '@tiptap/pm/view'
import { getCellsInRow, isColumnSelected, selectColumn } from './utils.js';
import { getCellsInRow, isColumnSelected, selectColumn } from './utils.js'
export const TableHeader = TiptapTableHeader.extend({
addAttributes() {
@ -16,74 +16,74 @@ export const TableHeader = TiptapTableHeader.extend({
colwidth: {
default: null,
parseHTML: (element) => {
const colwidth = element.getAttribute('colwidth');
const value = colwidth ? colwidth.split(',').map((item) => parseInt(item, 10)) : null;
const colwidth = element.getAttribute('colwidth')
const value = colwidth ? colwidth.split(',').map((item) => parseInt(item, 10)) : null
return value;
return value
}
},
style: {
default: null
}
};
}
},
addProseMirrorPlugins() {
const { isEditable } = this.editor;
const { isEditable } = this.editor
return [
new Plugin({
props: {
decorations: (state) => {
if (!isEditable) {
return DecorationSet.empty;
return DecorationSet.empty
}
const { doc, selection } = state;
const decorations: Decoration[] = [];
const cells = getCellsInRow(0)(selection);
const { doc, selection } = state
const decorations: Decoration[] = []
const cells = getCellsInRow(0)(selection)
if (cells) {
cells.forEach(({ pos }: { pos: number }, index: number) => {
decorations.push(
Decoration.widget(pos + 1, () => {
const colSelected = isColumnSelected(index)(selection);
let className = 'grip-column';
const colSelected = isColumnSelected(index)(selection)
let className = 'grip-column'
if (colSelected) {
className += ' selected';
className += ' selected'
}
if (index === 0) {
className += ' first';
className += ' first'
}
if (index === cells.length - 1) {
className += ' last';
className += ' last'
}
const grip = document.createElement('a');
const grip = document.createElement('a')
grip.className = className;
grip.className = className
grip.addEventListener('mousedown', (event) => {
event.preventDefault();
event.stopImmediatePropagation();
event.preventDefault()
event.stopImmediatePropagation()
this.editor.view.dispatch(selectColumn(index)(this.editor.state.tr));
});
return grip;
this.editor.view.dispatch(selectColumn(index)(this.editor.state.tr))
})
return grip
})
)
})
);
});
}
return DecorationSet.create(doc, decorations);
return DecorationSet.create(doc, decorations)
}
}
})
];
]
}
});
})
export default TableHeader;
export default TableHeader

View file

@ -1,8 +1,8 @@
import TiptapTableRow from '@tiptap/extension-table-row';
import TiptapTableRow from '@tiptap/extension-table-row'
export const TableRow = TiptapTableRow.extend({
allowGapCursor: false,
content: 'tableCell*'
});
})
export default TableRow;
export default TableRow

View file

@ -1,9 +1,9 @@
import TiptapTable from '@tiptap/extension-table';
import TiptapTable from '@tiptap/extension-table'
export const Table = TiptapTable.configure({
resizable: true,
lastColumnResizable: true,
allowTableNodeSelection: true
});
})
export default Table;
export default Table

View file

@ -1,85 +1,85 @@
import { Editor, findParentNode } from '@tiptap/core';
import { EditorState, Selection, Transaction } from '@tiptap/pm/state';
import { CellSelection, type Rect, TableMap } from '@tiptap/pm/tables';
import { Node, ResolvedPos } from '@tiptap/pm/model';
import type { EditorView } from '@tiptap/pm/view';
import Table from './table.js';
import { Editor, findParentNode } from '@tiptap/core'
import { EditorState, Selection, Transaction } from '@tiptap/pm/state'
import { CellSelection, type Rect, TableMap } from '@tiptap/pm/tables'
import { Node, ResolvedPos } from '@tiptap/pm/model'
import type { EditorView } from '@tiptap/pm/view'
import Table from './table.js'
export const isRectSelected = (rect: Rect) => (selection: CellSelection) => {
const map = TableMap.get(selection.$anchorCell.node(-1));
const start = selection.$anchorCell.start(-1);
const cells = map.cellsInRect(rect);
const map = TableMap.get(selection.$anchorCell.node(-1))
const start = selection.$anchorCell.start(-1)
const cells = map.cellsInRect(rect)
const selectedCells = map.cellsInRect(
map.rectBetween(selection.$anchorCell.pos - start, selection.$headCell.pos - start)
);
)
for (let i = 0, count = cells.length; i < count; i += 1) {
if (selectedCells.indexOf(cells[i]) === -1) {
return false;
return false
}
}
return true;
};
return true
}
export const findTable = (selection: Selection) =>
findParentNode((node) => node.type.spec.tableRole && node.type.spec.tableRole === 'table')(
selection
);
)
export const isCellSelection = (selection: Selection): selection is CellSelection =>
selection instanceof CellSelection;
selection instanceof CellSelection
export const isColumnSelected = (columnIndex: number) => (selection: Selection) => {
if (isCellSelection(selection)) {
const map = TableMap.get(selection.$anchorCell.node(-1));
const map = TableMap.get(selection.$anchorCell.node(-1))
return isRectSelected({
left: columnIndex,
right: columnIndex + 1,
top: 0,
bottom: map.height
})(selection);
})(selection)
}
return false;
};
return false
}
export const isRowSelected = (rowIndex: number) => (selection: Selection) => {
if (isCellSelection(selection)) {
const map = TableMap.get(selection.$anchorCell.node(-1));
const map = TableMap.get(selection.$anchorCell.node(-1))
return isRectSelected({
left: 0,
right: map.width,
top: rowIndex,
bottom: rowIndex + 1
})(selection);
})(selection)
}
return false;
};
return false
}
export const isTableSelected = (selection: Selection) => {
if (isCellSelection(selection)) {
const map = TableMap.get(selection.$anchorCell.node(-1));
const map = TableMap.get(selection.$anchorCell.node(-1))
return isRectSelected({
left: 0,
right: map.width,
top: 0,
bottom: map.height
})(selection);
})(selection)
}
return false;
};
return false
}
export const getCellsInColumn = (columnIndex: number | number[]) => (selection: Selection) => {
const table = findTable(selection);
const table = findTable(selection)
if (table) {
const map = TableMap.get(table.node);
const indexes = Array.isArray(columnIndex) ? columnIndex : Array.from([columnIndex]);
const map = TableMap.get(table.node)
const indexes = Array.isArray(columnIndex) ? columnIndex : Array.from([columnIndex])
return indexes.reduce(
(acc, index) => {
@ -89,32 +89,32 @@ export const getCellsInColumn = (columnIndex: number | number[]) => (selection:
right: index + 1,
top: 0,
bottom: map.height
});
})
return acc.concat(
cells.map((nodePos) => {
const node = table.node.nodeAt(nodePos);
const pos = nodePos + table.start;
const node = table.node.nodeAt(nodePos)
const pos = nodePos + table.start
return { pos, start: pos + 1, node };
return { pos, start: pos + 1, node }
})
);
)
}
return acc;
return acc
},
[] as { pos: number; start: number; node: Node | null | undefined }[]
);
)
}
return null
}
return null;
};
export const getCellsInRow = (rowIndex: number | number[]) => (selection: Selection) => {
const table = findTable(selection);
const table = findTable(selection)
if (table) {
const map = TableMap.get(table.node);
const indexes = Array.isArray(rowIndex) ? rowIndex : Array.from([rowIndex]);
const map = TableMap.get(table.node)
const indexes = Array.isArray(rowIndex) ? rowIndex : Array.from([rowIndex])
return indexes.reduce(
(acc, index) => {
@ -124,55 +124,55 @@ export const getCellsInRow = (rowIndex: number | number[]) => (selection: Select
right: map.width,
top: index,
bottom: index + 1
});
})
return acc.concat(
cells.map((nodePos) => {
const node = table.node.nodeAt(nodePos);
const pos = nodePos + table.start;
return { pos, start: pos + 1, node };
const node = table.node.nodeAt(nodePos)
const pos = nodePos + table.start
return { pos, start: pos + 1, node }
})
);
)
}
return acc;
return acc
},
[] as { pos: number; start: number; node: Node | null | undefined }[]
);
)
}
return null;
};
return null
}
export const getCellsInTable = (selection: Selection) => {
const table = findTable(selection);
const table = findTable(selection)
if (table) {
const map = TableMap.get(table.node);
const map = TableMap.get(table.node)
const cells = map.cellsInRect({
left: 0,
right: map.width,
top: 0,
bottom: map.height
});
})
return cells.map((nodePos) => {
const node = table.node.nodeAt(nodePos);
const pos = nodePos + table.start;
const node = table.node.nodeAt(nodePos)
const pos = nodePos + table.start
return { pos, start: pos + 1, node };
});
return { pos, start: pos + 1, node }
})
}
return null;
};
return null
}
export const findParentNodeClosestToPos = (
$pos: ResolvedPos,
predicate: (node: Node) => boolean
) => {
for (let i = $pos.depth; i > 0; i -= 1) {
const node = $pos.node(i);
const node = $pos.node(i)
if (predicate(node)) {
return {
@ -180,40 +180,40 @@ export const findParentNodeClosestToPos = (
start: $pos.start(i),
depth: i,
node
};
}
}
}
return null;
};
return null
}
export const findCellClosestToPos = ($pos: ResolvedPos) => {
const predicate = (node: Node) =>
node.type.spec.tableRole && /cell/i.test(node.type.spec.tableRole);
node.type.spec.tableRole && /cell/i.test(node.type.spec.tableRole)
return findParentNodeClosestToPos($pos, predicate);
};
return findParentNodeClosestToPos($pos, predicate)
}
const select = (type: 'row' | 'column') => (index: number) => (tr: Transaction) => {
const table = findTable(tr.selection);
const isRowSelection = type === 'row';
const table = findTable(tr.selection)
const isRowSelection = type === 'row'
if (table) {
const map = TableMap.get(table.node);
const map = TableMap.get(table.node)
// Check if the index is valid
if (index >= 0 && index < (isRowSelection ? map.height : map.width)) {
const left = isRowSelection ? 0 : index;
const top = isRowSelection ? index : 0;
const right = isRowSelection ? map.width : index + 1;
const bottom = isRowSelection ? index + 1 : map.height;
const left = isRowSelection ? 0 : index
const top = isRowSelection ? index : 0
const right = isRowSelection ? map.width : index + 1
const bottom = isRowSelection ? index + 1 : map.height
const cellsInFirstRow = map.cellsInRect({
left,
top,
right: isRowSelection ? right : left + 1,
bottom: isRowSelection ? top + 1 : bottom
});
})
const cellsInLastRow =
bottom - top === 1
@ -223,41 +223,41 @@ const select = (type: 'row' | 'column') => (index: number) => (tr: Transaction)
top: isRowSelection ? bottom - 1 : top,
right,
bottom
});
})
const head = table.start + cellsInFirstRow[0];
const anchor = table.start + cellsInLastRow[cellsInLastRow.length - 1];
const $head = tr.doc.resolve(head);
const $anchor = tr.doc.resolve(anchor);
const head = table.start + cellsInFirstRow[0]
const anchor = table.start + cellsInLastRow[cellsInLastRow.length - 1]
const $head = tr.doc.resolve(head)
const $anchor = tr.doc.resolve(anchor)
return tr.setSelection(new CellSelection($anchor, $head));
return tr.setSelection(new CellSelection($anchor, $head))
}
}
return tr;
};
return tr
}
export const selectColumn = select('column');
export const selectColumn = select('column')
export const selectRow = select('row');
export const selectRow = select('row')
export const selectTable = (tr: Transaction) => {
const table = findTable(tr.selection);
const table = findTable(tr.selection)
if (table) {
const { map } = TableMap.get(table.node);
const { map } = TableMap.get(table.node)
if (map && map.length) {
const head = table.start + map[0];
const anchor = table.start + map[map.length - 1];
const $head = tr.doc.resolve(head);
const $anchor = tr.doc.resolve(anchor);
const head = table.start + map[0]
const anchor = table.start + map[map.length - 1]
const $head = tr.doc.resolve(head)
const $anchor = tr.doc.resolve(anchor)
return tr.setSelection(new CellSelection($anchor, $head));
return tr.setSelection(new CellSelection($anchor, $head))
}
}
return tr;
};
return tr
}
export const isColumnGripSelected = ({
editor,
@ -265,30 +265,30 @@ export const isColumnGripSelected = ({
state,
from
}: {
editor: Editor;
view: EditorView;
state: EditorState;
from: number;
editor: Editor
view: EditorView
state: EditorState
from: number
}) => {
const domAtPos = view.domAtPos(from).node as HTMLElement;
const nodeDOM = view.nodeDOM(from) as HTMLElement;
const node = nodeDOM || domAtPos;
const domAtPos = view.domAtPos(from).node as HTMLElement
const nodeDOM = view.nodeDOM(from) as HTMLElement
const node = nodeDOM || domAtPos
if (!editor.isActive(Table.name) || !node || isTableSelected(state.selection)) {
return false;
return false
}
let container = node;
let container = node
while (container && !['TD', 'TH'].includes(container.tagName)) {
container = container.parentElement!;
container = container.parentElement!
}
const gripColumn =
container && container.querySelector && container.querySelector('a.grip-column.selected');
container && container.querySelector && container.querySelector('a.grip-column.selected')
return !!gripColumn;
};
return !!gripColumn
}
export const isRowGripSelected = ({
editor,
@ -296,27 +296,27 @@ export const isRowGripSelected = ({
state,
from
}: {
editor: Editor;
view: EditorView;
state: EditorState;
from: number;
editor: Editor
view: EditorView
state: EditorState
from: number
}) => {
const domAtPos = view.domAtPos(from).node as HTMLElement;
const nodeDOM = view.nodeDOM(from) as HTMLElement;
const node = nodeDOM || domAtPos;
const domAtPos = view.domAtPos(from).node as HTMLElement
const nodeDOM = view.nodeDOM(from) as HTMLElement
const node = nodeDOM || domAtPos
if (!editor.isActive(Table.name) || !node || isTableSelected(state.selection)) {
return false;
return false
}
let container = node;
let container = node
while (container && !['TD', 'TH'].includes(container.tagName)) {
container = container.parentElement!;
container = container.parentElement!
}
const gripRow =
container && container.querySelector && container.querySelector('a.grip-row.selected');
container && container.querySelector && container.querySelector('a.grip-row.selected')
return !!gripRow;
};
return !!gripRow
}

View file

@ -1,7 +1,7 @@
import { SvelteNodeViewRenderer } from 'svelte-tiptap';
import { Video } from './VideoExtension.js';
import type { NodeViewProps } from '@tiptap/core';
import type { Component } from 'svelte';
import { SvelteNodeViewRenderer } from 'svelte-tiptap'
import { Video } from './VideoExtension.js'
import type { NodeViewProps } from '@tiptap/core'
import type { Component } from 'svelte'
export const VideoExtended = (content: Component<NodeViewProps>) =>
Video.extend({
@ -25,10 +25,10 @@ export const VideoExtended = (content: Component<NodeViewProps>) =>
align: {
default: 'left'
}
};
}
},
addNodeView: () => {
return SvelteNodeViewRenderer(content);
return SvelteNodeViewRenderer(content)
}
});
})

View file

@ -1,8 +1,8 @@
import { Node, nodeInputRule } from '@tiptap/core';
import { Plugin, PluginKey } from '@tiptap/pm/state';
import { Node, nodeInputRule } from '@tiptap/core'
import { Plugin, PluginKey } from '@tiptap/pm/state'
export interface VideoOptions {
HTMLAttributes: Record<string, unknown>;
HTMLAttributes: Record<string, unknown>
}
declare module '@tiptap/core' {
@ -11,20 +11,20 @@ declare module '@tiptap/core' {
/**
* Set a video node
*/
setVideo: (src: string) => ReturnType;
setVideo: (src: string) => ReturnType
/**
* Toggle a video
*/
toggleVideo: (src: string) => ReturnType;
toggleVideo: (src: string) => ReturnType
/**
* Remove a video
*/
removeVideo: () => ReturnType;
};
removeVideo: () => ReturnType
}
}
}
const VIDEO_INPUT_REGEX = /!\[(.+|:?)]\((\S+)(?:(?:\s+)["'](\S+)["'])?\)/;
const VIDEO_INPUT_REGEX = /!\[(.+|:?)]\((\S+)(?:(?:\s+)["'](\S+)["'])?\)/
export const Video = Node.create<VideoOptions>({
name: 'video',
@ -35,7 +35,7 @@ export const Video = Node.create<VideoOptions>({
addOptions() {
return {
HTMLAttributes: {}
};
}
},
addAttributes() {
return {
@ -44,7 +44,7 @@ export const Video = Node.create<VideoOptions>({
parseHTML: (el) => (el as HTMLSpanElement).getAttribute('src'),
renderHTML: (attrs) => ({ src: attrs.src })
}
};
}
},
parseHTML() {
return [
@ -52,7 +52,7 @@ export const Video = Node.create<VideoOptions>({
tag: 'video',
getAttrs: (el) => ({ src: (el as HTMLVideoElement).getAttribute('src') })
}
];
]
},
renderHTML({ HTMLAttributes }) {
@ -60,7 +60,7 @@ export const Video = Node.create<VideoOptions>({
'video',
{ controls: 'true', style: 'width: fit-content;', ...HTMLAttributes },
['source', HTMLAttributes]
];
]
},
addCommands() {
return {
@ -79,7 +79,7 @@ export const Video = Node.create<VideoOptions>({
() =>
({ commands }) =>
commands.deleteNode(this.name)
};
}
},
addInputRules() {
return [
@ -87,12 +87,12 @@ export const Video = Node.create<VideoOptions>({
find: VIDEO_INPUT_REGEX,
type: this.type,
getAttributes: (match) => {
const [, , src] = match;
const [, , src] = match
return { src };
return { src }
}
})
];
]
},
addProseMirrorPlugins() {
return [
@ -105,43 +105,43 @@ export const Video = Node.create<VideoOptions>({
const {
state: { schema, tr },
dispatch
} = view;
} = view
const hasFiles =
event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length;
event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length
if (!hasFiles) return false;
if (!hasFiles) return false
const videos = Array.from(event.dataTransfer.files).filter((file) =>
/video/i.test(file.type)
);
)
if (videos.length === 0) return false;
if (videos.length === 0) return false
event.preventDefault();
event.preventDefault()
const coordinates = view.posAtCoords({ left: event.clientX, top: event.clientY });
const coordinates = view.posAtCoords({ left: event.clientX, top: event.clientY })
videos.forEach((video) => {
const reader = new FileReader();
const reader = new FileReader()
reader.onload = (readerEvent) => {
const node = schema.nodes.video.create({ src: readerEvent.target?.result });
const node = schema.nodes.video.create({ src: readerEvent.target?.result })
if (coordinates && typeof coordinates.pos === 'number') {
const transaction = tr.insert(coordinates?.pos, node);
const transaction = tr.insert(coordinates?.pos, node)
dispatch(transaction);
dispatch(transaction)
}
}
};
reader.readAsDataURL(video);
});
reader.readAsDataURL(video)
})
return true;
return true
}
}
}
})
];
]
}
});
})

View file

@ -1,15 +1,15 @@
import { Editor, Node, mergeAttributes, type CommandProps, type NodeViewProps } from '@tiptap/core';
import type { Component } from 'svelte';
import { SvelteNodeViewRenderer } from 'svelte-tiptap';
import { Editor, Node, mergeAttributes, type CommandProps, type NodeViewProps } from '@tiptap/core'
import type { Component } from 'svelte'
import { SvelteNodeViewRenderer } from 'svelte-tiptap'
export interface VideoPlaceholderOptions {
HTMLAttributes: Record<string, object>;
onDrop: (files: File[], editor: Editor) => void;
onDropRejected?: (files: File[], editor: Editor) => void;
onEmbed: (url: string, editor: Editor) => void;
allowedMimeTypes?: Record<string, string[]>;
maxFiles?: number;
maxSize?: number;
HTMLAttributes: Record<string, object>
onDrop: (files: File[], editor: Editor) => void
onDropRejected?: (files: File[], editor: Editor) => void
onEmbed: (url: string, editor: Editor) => void
allowedMimeTypes?: Record<string, string[]>
maxFiles?: number
maxSize?: number
}
declare module '@tiptap/core' {
@ -18,8 +18,8 @@ declare module '@tiptap/core' {
/**
* Inserts a video placeholder
*/
insertVideoPlaceholder: () => ReturnType;
};
insertVideoPlaceholder: () => ReturnType
}
}
}
@ -32,14 +32,14 @@ export const VideoPlaceholder = (content: Component<NodeViewProps>) =>
onDrop: () => {},
onDropRejected: () => {},
onEmbed: () => {}
};
}
},
parseHTML() {
return [{ tag: `div[data-type="${this.name}"]` }];
return [{ tag: `div[data-type="${this.name}"]` }]
},
renderHTML({ HTMLAttributes }) {
return ['div', mergeAttributes(HTMLAttributes)];
return ['div', mergeAttributes(HTMLAttributes)]
},
group: 'block',
draggable: true,
@ -48,15 +48,15 @@ export const VideoPlaceholder = (content: Component<NodeViewProps>) =>
isolating: true,
addNodeView() {
return SvelteNodeViewRenderer(content);
return SvelteNodeViewRenderer(content)
},
addCommands() {
return {
insertVideoPlaceholder: () => (props: CommandProps) => {
return props.commands.insertContent({
type: 'video-placeholder'
});
})
}
};
}
});
}
})

View file

@ -1,112 +1,112 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { NodeViewWrapper } from 'svelte-tiptap';
import type { NodeViewProps } from '@tiptap/core';
import { onDestroy, onMount } from 'svelte'
import { NodeViewWrapper } from 'svelte-tiptap'
import type { NodeViewProps } from '@tiptap/core'
import AlignLeft from 'lucide-svelte/icons/align-left';
import AlignCenter from 'lucide-svelte/icons/align-center';
import AlignRight from 'lucide-svelte/icons/align-right';
import CopyIcon from 'lucide-svelte/icons/copy';
import Fullscreen from 'lucide-svelte/icons/fullscreen';
import Trash from 'lucide-svelte/icons/trash';
import Captions from 'lucide-svelte/icons/captions';
import AlignLeft from 'lucide-svelte/icons/align-left'
import AlignCenter from 'lucide-svelte/icons/align-center'
import AlignRight from 'lucide-svelte/icons/align-right'
import CopyIcon from 'lucide-svelte/icons/copy'
import Fullscreen from 'lucide-svelte/icons/fullscreen'
import Trash from 'lucide-svelte/icons/trash'
import Captions from 'lucide-svelte/icons/captions'
import { duplicateContent } from '../../utils.js';
import { duplicateContent } from '../../utils.js'
const { node, editor, selected, deleteNode, updateAttributes }: NodeViewProps = $props();
const { node, editor, selected, deleteNode, updateAttributes }: NodeViewProps = $props()
const minWidth = 150;
const minWidth = 150
let audRef: HTMLAudioElement;
let nodeRef: HTMLDivElement;
let audRef: HTMLAudioElement
let nodeRef: HTMLDivElement
let caption: string | null = $state(node.attrs.title);
let caption: string | null = $state(node.attrs.title)
$effect(() => {
if (caption?.trim() === '') caption = null;
updateAttributes({ title: caption });
});
if (caption?.trim() === '') caption = null
updateAttributes({ title: caption })
})
let resizing = $state(false);
let resizingInitialWidth = $state(0);
let resizingInitialMouseX = $state(0);
let resizingPosition = $state<'left' | 'right'>('left');
let resizing = $state(false)
let resizingInitialWidth = $state(0)
let resizingInitialMouseX = $state(0)
let resizingPosition = $state<'left' | 'right'>('left')
function handleResizingPosition(e: MouseEvent, position: 'left' | 'right') {
startResize(e);
resizingPosition = position;
startResize(e)
resizingPosition = position
}
function startResize(e: MouseEvent) {
e.preventDefault();
resizing = true;
resizingInitialMouseX = e.clientX;
if (audRef) resizingInitialWidth = audRef.offsetWidth;
e.preventDefault()
resizing = true
resizingInitialMouseX = e.clientX
if (audRef) resizingInitialWidth = audRef.offsetWidth
}
function resize(e: MouseEvent) {
if (!resizing) return;
let dx = e.clientX - resizingInitialMouseX;
if (!resizing) return
let dx = e.clientX - resizingInitialMouseX
if (resizingPosition === 'left') {
dx = resizingInitialMouseX - e.clientX;
dx = resizingInitialMouseX - e.clientX
}
const newWidth = Math.max(resizingInitialWidth + dx, minWidth);
const parentWidth = nodeRef?.parentElement?.offsetWidth || 0;
const newWidth = Math.max(resizingInitialWidth + dx, minWidth)
const parentWidth = nodeRef?.parentElement?.offsetWidth || 0
if (newWidth < parentWidth) {
updateAttributes({ width: newWidth });
updateAttributes({ width: newWidth })
}
}
function endResize() {
resizing = false;
resizingInitialMouseX = 0;
resizingInitialWidth = 0;
resizing = false
resizingInitialMouseX = 0
resizingInitialWidth = 0
}
function handleTouchStart(e: TouchEvent, position: 'left' | 'right') {
e.preventDefault();
resizing = true;
resizingPosition = position;
resizingInitialMouseX = e.touches[0].clientX;
if (audRef) resizingInitialWidth = audRef.offsetWidth;
e.preventDefault()
resizing = true
resizingPosition = position
resizingInitialMouseX = e.touches[0].clientX
if (audRef) resizingInitialWidth = audRef.offsetWidth
}
function handleTouchMove(e: TouchEvent) {
if (!resizing) return;
let dx = e.touches[0].clientX - resizingInitialMouseX;
if (!resizing) return
let dx = e.touches[0].clientX - resizingInitialMouseX
if (resizingPosition === 'left') {
dx = resizingInitialMouseX - e.touches[0].clientX;
dx = resizingInitialMouseX - e.touches[0].clientX
}
const newWidth = Math.max(resizingInitialWidth + dx, minWidth);
const parentWidth = nodeRef?.parentElement?.offsetWidth || 0;
const newWidth = Math.max(resizingInitialWidth + dx, minWidth)
const parentWidth = nodeRef?.parentElement?.offsetWidth || 0
if (newWidth < parentWidth) {
updateAttributes({ width: newWidth });
updateAttributes({ width: newWidth })
}
}
function handleTouchEnd() {
resizing = false;
resizingInitialMouseX = 0;
resizingInitialWidth = 0;
resizing = false
resizingInitialMouseX = 0
resizingInitialWidth = 0
}
onMount(() => {
// Attach id to nodeRef
nodeRef = document.getElementById('resizable-container-audio') as HTMLDivElement;
nodeRef = document.getElementById('resizable-container-audio') as HTMLDivElement
// Mouse events
window.addEventListener('mousemove', resize);
window.addEventListener('mouseup', endResize);
window.addEventListener('mousemove', resize)
window.addEventListener('mouseup', endResize)
// Touch events
window.addEventListener('touchmove', handleTouchMove);
window.addEventListener('touchend', handleTouchEnd);
});
window.addEventListener('touchmove', handleTouchMove)
window.addEventListener('touchend', handleTouchEnd)
})
onDestroy(() => {
window.removeEventListener('mousemove', resize);
window.removeEventListener('mouseup', endResize);
window.removeEventListener('touchmove', handleTouchMove);
window.removeEventListener('touchend', handleTouchEnd);
});
window.removeEventListener('mousemove', resize)
window.removeEventListener('mouseup', endResize)
window.removeEventListener('touchmove', handleTouchMove)
window.removeEventListener('touchend', handleTouchEnd)
})
</script>
<NodeViewWrapper
@ -136,10 +136,10 @@
aria-label="Resize left"
class="edra-media-resize-handle edra-media-resize-handle-left"
onmousedown={(event: MouseEvent) => {
handleResizingPosition(event, 'left');
handleResizingPosition(event, 'left')
}}
ontouchstart={(event: TouchEvent) => {
handleTouchStart(event, 'left');
handleTouchStart(event, 'left')
}}
>
<div class="edra-media-resize-indicator"></div>
@ -151,10 +151,10 @@
aria-label="Resize right"
class="edra-media-resize-handle edra-media-resize-handle-right"
onmousedown={(event: MouseEvent) => {
handleResizingPosition(event, 'right');
handleResizingPosition(event, 'right')
}}
ontouchstart={(event: TouchEvent) => {
handleTouchStart(event, 'right');
handleTouchStart(event, 'right')
}}
>
<div class="edra-media-resize-indicator"></div>
@ -185,7 +185,7 @@
<button
class="edra-toolbar-button"
onclick={() => {
if (caption === null || caption.trim() === '') caption = 'Audio Caption';
if (caption === null || caption.trim() === '') caption = 'Audio Caption'
}}
title="Caption"
>
@ -194,7 +194,7 @@
<button
class="edra-toolbar-button"
onclick={() => {
duplicateContent(editor, node);
duplicateContent(editor, node)
}}
title="Duplicate"
>
@ -205,7 +205,7 @@
onclick={() => {
updateAttributes({
width: 'fit-content'
});
})
}}
title="Full Screen"
>
@ -214,7 +214,7 @@
<button
class="edra-toolbar-button edra-destructive"
onclick={() => {
deleteNode();
deleteNode()
}}
title="Delete"
>

View file

@ -1,17 +1,17 @@
<script lang="ts">
import type { NodeViewProps } from '@tiptap/core';
import AudioLines from 'lucide-svelte/icons/audio-lines';
import { NodeViewWrapper } from 'svelte-tiptap';
const { editor }: NodeViewProps = $props();
import type { NodeViewProps } from '@tiptap/core'
import AudioLines from 'lucide-svelte/icons/audio-lines'
import { NodeViewWrapper } from 'svelte-tiptap'
const { editor }: NodeViewProps = $props()
function handleClick(e: MouseEvent) {
if (!editor.isEditable) return;
e.preventDefault();
const audioUrl = prompt('Enter the URL of an audio:');
if (!editor.isEditable) return
e.preventDefault()
const audioUrl = prompt('Enter the URL of an audio:')
if (!audioUrl) {
return;
return
}
editor.chain().focus().setAudio(audioUrl).run();
editor.chain().focus().setAudio(audioUrl).run()
}
</script>

View file

@ -1,28 +1,28 @@
<script lang="ts">
import { NodeViewWrapper, NodeViewContent } from 'svelte-tiptap';
import type { NodeViewProps } from '@tiptap/core';
const { node, updateAttributes, extension }: NodeViewProps = $props();
import { NodeViewWrapper, NodeViewContent } from 'svelte-tiptap'
import type { NodeViewProps } from '@tiptap/core'
const { node, updateAttributes, extension }: NodeViewProps = $props()
let preRef = $state<HTMLPreElement>();
let preRef = $state<HTMLPreElement>()
let isCopying = $state(false);
let isCopying = $state(false)
const languages: string[] = extension.options.lowlight.listLanguages().sort();
const languages: string[] = extension.options.lowlight.listLanguages().sort()
let defaultLanguage = $state(node.attrs.language);
let defaultLanguage = $state(node.attrs.language)
$effect(() => {
updateAttributes({ language: defaultLanguage });
});
updateAttributes({ language: defaultLanguage })
})
function copyCode() {
if (isCopying) return;
if (!preRef) return;
isCopying = true;
navigator.clipboard.writeText(preRef.innerText);
if (isCopying) return
if (!preRef) return
isCopying = true
navigator.clipboard.writeText(preRef.innerText)
setTimeout(() => {
isCopying = false;
}, 1000);
isCopying = false
}, 1000)
}
</script>

View file

@ -1,27 +1,27 @@
<script lang="ts">
import type { EdraCommand } from '../../commands/types.js';
import type { Editor } from '@tiptap/core';
import { icons } from 'lucide-svelte';
import type { EdraCommand } from '../../commands/types.js'
import type { Editor } from '@tiptap/core'
import { icons } from 'lucide-svelte'
interface Props {
command: EdraCommand;
editor: Editor;
style?: string;
onclick?: () => void;
command: EdraCommand
editor: Editor
style?: string
onclick?: () => void
}
const { command, editor, style, onclick }: Props = $props();
const { command, editor, style, onclick }: Props = $props()
const Icon = icons[command.iconName];
const shortcut = command.shortCuts ? ` (${command.shortCuts[0]})` : '';
const Icon = icons[command.iconName]
const shortcut = command.shortCuts ? ` (${command.shortCuts[0]})` : ''
</script>
<button
class="edra-command-button"
class:active={editor.isActive(command.name) || command.isActive?.(editor)}
onclick={() => {
if (onclick !== undefined) onclick();
else command.action(editor);
if (onclick !== undefined) onclick()
else command.action(editor)
}}
title={`${command.label}${shortcut}`}
{style}

View file

@ -1,112 +1,112 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { NodeViewWrapper } from 'svelte-tiptap';
import type { NodeViewProps } from '@tiptap/core';
import { onDestroy, onMount } from 'svelte'
import { NodeViewWrapper } from 'svelte-tiptap'
import type { NodeViewProps } from '@tiptap/core'
import AlignLeft from 'lucide-svelte/icons/align-left';
import AlignCenter from 'lucide-svelte/icons/align-center';
import AlignRight from 'lucide-svelte/icons/align-right';
import CopyIcon from 'lucide-svelte/icons/copy';
import Fullscreen from 'lucide-svelte/icons/fullscreen';
import Trash from 'lucide-svelte/icons/trash';
import Captions from 'lucide-svelte/icons/captions';
import AlignLeft from 'lucide-svelte/icons/align-left'
import AlignCenter from 'lucide-svelte/icons/align-center'
import AlignRight from 'lucide-svelte/icons/align-right'
import CopyIcon from 'lucide-svelte/icons/copy'
import Fullscreen from 'lucide-svelte/icons/fullscreen'
import Trash from 'lucide-svelte/icons/trash'
import Captions from 'lucide-svelte/icons/captions'
import { duplicateContent } from '../../utils.js';
import { duplicateContent } from '../../utils.js'
const { node, editor, selected, deleteNode, updateAttributes }: NodeViewProps = $props();
const { node, editor, selected, deleteNode, updateAttributes }: NodeViewProps = $props()
const minWidth = 150;
const minWidth = 150
let iframeRef: HTMLIFrameElement;
let nodeRef: HTMLDivElement;
let iframeRef: HTMLIFrameElement
let nodeRef: HTMLDivElement
let caption: string | null = $state(node.attrs.title);
let caption: string | null = $state(node.attrs.title)
$effect(() => {
if (caption?.trim() === '') caption = null;
updateAttributes({ title: caption });
});
if (caption?.trim() === '') caption = null
updateAttributes({ title: caption })
})
let resizing = $state(false);
let resizingInitialWidth = $state(0);
let resizingInitialMouseX = $state(0);
let resizingPosition = $state<'left' | 'right'>('left');
let resizing = $state(false)
let resizingInitialWidth = $state(0)
let resizingInitialMouseX = $state(0)
let resizingPosition = $state<'left' | 'right'>('left')
function handleResizingPosition(e: MouseEvent, position: 'left' | 'right') {
startResize(e);
resizingPosition = position;
startResize(e)
resizingPosition = position
}
function startResize(e: MouseEvent) {
e.preventDefault();
resizing = true;
resizingInitialMouseX = e.clientX;
if (iframeRef) resizingInitialWidth = iframeRef.offsetWidth;
e.preventDefault()
resizing = true
resizingInitialMouseX = e.clientX
if (iframeRef) resizingInitialWidth = iframeRef.offsetWidth
}
function resize(e: MouseEvent) {
if (!resizing) return;
let dx = e.clientX - resizingInitialMouseX;
if (!resizing) return
let dx = e.clientX - resizingInitialMouseX
if (resizingPosition === 'left') {
dx = resizingInitialMouseX - e.clientX;
dx = resizingInitialMouseX - e.clientX
}
const newWidth = Math.max(resizingInitialWidth + dx, minWidth);
const parentWidth = nodeRef?.parentElement?.offsetWidth || 0;
const newWidth = Math.max(resizingInitialWidth + dx, minWidth)
const parentWidth = nodeRef?.parentElement?.offsetWidth || 0
if (newWidth < parentWidth) {
updateAttributes({ width: newWidth });
updateAttributes({ width: newWidth })
}
}
function endResize() {
resizing = false;
resizingInitialMouseX = 0;
resizingInitialWidth = 0;
resizing = false
resizingInitialMouseX = 0
resizingInitialWidth = 0
}
function handleTouchStart(e: TouchEvent, position: 'left' | 'right') {
e.preventDefault();
resizing = true;
resizingPosition = position;
resizingInitialMouseX = e.touches[0].clientX;
if (iframeRef) resizingInitialWidth = iframeRef.offsetWidth;
e.preventDefault()
resizing = true
resizingPosition = position
resizingInitialMouseX = e.touches[0].clientX
if (iframeRef) resizingInitialWidth = iframeRef.offsetWidth
}
function handleTouchMove(e: TouchEvent) {
if (!resizing) return;
let dx = e.touches[0].clientX - resizingInitialMouseX;
if (!resizing) return
let dx = e.touches[0].clientX - resizingInitialMouseX
if (resizingPosition === 'left') {
dx = resizingInitialMouseX - e.touches[0].clientX;
dx = resizingInitialMouseX - e.touches[0].clientX
}
const newWidth = Math.max(resizingInitialWidth + dx, minWidth);
const parentWidth = nodeRef?.parentElement?.offsetWidth || 0;
const newWidth = Math.max(resizingInitialWidth + dx, minWidth)
const parentWidth = nodeRef?.parentElement?.offsetWidth || 0
if (newWidth < parentWidth) {
updateAttributes({ width: newWidth });
updateAttributes({ width: newWidth })
}
}
function handleTouchEnd() {
resizing = false;
resizingInitialMouseX = 0;
resizingInitialWidth = 0;
resizing = false
resizingInitialMouseX = 0
resizingInitialWidth = 0
}
onMount(() => {
// Attach id to nodeRef
nodeRef = document.getElementById('resizable-container-audio') as HTMLDivElement;
nodeRef = document.getElementById('resizable-container-audio') as HTMLDivElement
// Mouse events
window.addEventListener('mousemove', resize);
window.addEventListener('mouseup', endResize);
window.addEventListener('mousemove', resize)
window.addEventListener('mouseup', endResize)
// Touch events
window.addEventListener('touchmove', handleTouchMove);
window.addEventListener('touchend', handleTouchEnd);
});
window.addEventListener('touchmove', handleTouchMove)
window.addEventListener('touchend', handleTouchEnd)
})
onDestroy(() => {
window.removeEventListener('mousemove', resize);
window.removeEventListener('mouseup', endResize);
window.removeEventListener('touchmove', handleTouchMove);
window.removeEventListener('touchend', handleTouchEnd);
});
window.removeEventListener('mousemove', resize)
window.removeEventListener('mouseup', endResize)
window.removeEventListener('touchmove', handleTouchMove)
window.removeEventListener('touchend', handleTouchEnd)
})
</script>
<NodeViewWrapper
@ -130,10 +130,10 @@
aria-label="Resize left"
class="edra-media-resize-handle edra-media-resize-handle-left"
onmousedown={(event: MouseEvent) => {
handleResizingPosition(event, 'left');
handleResizingPosition(event, 'left')
}}
ontouchstart={(event: TouchEvent) => {
handleTouchStart(event, 'left');
handleTouchStart(event, 'left')
}}
>
<div class="edra-media-resize-indicator"></div>
@ -145,10 +145,10 @@
aria-label="Resize right"
class="edra-media-resize-handle edra-media-resize-handle-right"
onmousedown={(event: MouseEvent) => {
handleResizingPosition(event, 'right');
handleResizingPosition(event, 'right')
}}
ontouchstart={(event: TouchEvent) => {
handleTouchStart(event, 'right');
handleTouchStart(event, 'right')
}}
>
<div class="edra-media-resize-indicator"></div>
@ -179,7 +179,7 @@
<button
class="edra-toolbar-button"
onclick={() => {
if (caption === null || caption.trim() === '') caption = 'Audio Caption';
if (caption === null || caption.trim() === '') caption = 'Audio Caption'
}}
title="Caption"
>
@ -188,7 +188,7 @@
<button
class="edra-toolbar-button"
onclick={() => {
duplicateContent(editor, node);
duplicateContent(editor, node)
}}
title="Duplicate"
>
@ -199,7 +199,7 @@
onclick={() => {
updateAttributes({
width: 'fit-content'
});
})
}}
title="Full Screen"
>
@ -208,7 +208,7 @@
<button
class="edra-toolbar-button edra-destructive"
onclick={() => {
deleteNode();
deleteNode()
}}
title="Delete"
>

View file

@ -1,17 +1,17 @@
<script lang="ts">
import type { NodeViewProps } from '@tiptap/core';
import CodeXML from 'lucide-svelte/icons/code-xml';
import { NodeViewWrapper } from 'svelte-tiptap';
const { editor }: NodeViewProps = $props();
import type { NodeViewProps } from '@tiptap/core'
import CodeXML from 'lucide-svelte/icons/code-xml'
import { NodeViewWrapper } from 'svelte-tiptap'
const { editor }: NodeViewProps = $props()
function handleClick(e: MouseEvent) {
if (!editor.isEditable) return;
e.preventDefault();
const iFrameURL = prompt('Enter the URL of an iFrame:');
if (!editor.isEditable) return
e.preventDefault()
const iFrameURL = prompt('Enter the URL of an iFrame:')
if (!iFrameURL) {
return;
return
}
editor.chain().focus().setIframe({ src: iFrameURL }).run();
editor.chain().focus().setIframe({ src: iFrameURL }).run()
}
</script>

View file

@ -1,112 +1,112 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { NodeViewWrapper } from 'svelte-tiptap';
import type { NodeViewProps } from '@tiptap/core';
import { onDestroy, onMount } from 'svelte'
import { NodeViewWrapper } from 'svelte-tiptap'
import type { NodeViewProps } from '@tiptap/core'
import AlignLeft from 'lucide-svelte/icons/align-left';
import AlignCenter from 'lucide-svelte/icons/align-center';
import AlignRight from 'lucide-svelte/icons/align-right';
import CopyIcon from 'lucide-svelte/icons/copy';
import Fullscreen from 'lucide-svelte/icons/fullscreen';
import Trash from 'lucide-svelte/icons/trash';
import Captions from 'lucide-svelte/icons/captions';
import AlignLeft from 'lucide-svelte/icons/align-left'
import AlignCenter from 'lucide-svelte/icons/align-center'
import AlignRight from 'lucide-svelte/icons/align-right'
import CopyIcon from 'lucide-svelte/icons/copy'
import Fullscreen from 'lucide-svelte/icons/fullscreen'
import Trash from 'lucide-svelte/icons/trash'
import Captions from 'lucide-svelte/icons/captions'
import { duplicateContent } from '../../utils.js';
import { duplicateContent } from '../../utils.js'
const { node, editor, selected, deleteNode, updateAttributes }: NodeViewProps = $props();
const { node, editor, selected, deleteNode, updateAttributes }: NodeViewProps = $props()
const minWidth = 150;
const minWidth = 150
let imgRef: HTMLImageElement;
let nodeRef: HTMLDivElement;
let imgRef: HTMLImageElement
let nodeRef: HTMLDivElement
let caption: string | null = $state(node.attrs.title);
let caption: string | null = $state(node.attrs.title)
$effect(() => {
if (caption?.trim() === '') caption = null;
updateAttributes({ title: caption });
});
if (caption?.trim() === '') caption = null
updateAttributes({ title: caption })
})
let resizing = $state(false);
let resizingInitialWidth = $state(0);
let resizingInitialMouseX = $state(0);
let resizingPosition = $state<'left' | 'right'>('left');
let resizing = $state(false)
let resizingInitialWidth = $state(0)
let resizingInitialMouseX = $state(0)
let resizingPosition = $state<'left' | 'right'>('left')
function handleResizingPosition(e: MouseEvent, position: 'left' | 'right') {
startResize(e);
resizingPosition = position;
startResize(e)
resizingPosition = position
}
function startResize(e: MouseEvent) {
e.preventDefault();
resizing = true;
resizingInitialMouseX = e.clientX;
if (imgRef) resizingInitialWidth = imgRef.offsetWidth;
e.preventDefault()
resizing = true
resizingInitialMouseX = e.clientX
if (imgRef) resizingInitialWidth = imgRef.offsetWidth
}
function resize(e: MouseEvent) {
if (!resizing) return;
let dx = e.clientX - resizingInitialMouseX;
if (!resizing) return
let dx = e.clientX - resizingInitialMouseX
if (resizingPosition === 'left') {
dx = resizingInitialMouseX - e.clientX;
dx = resizingInitialMouseX - e.clientX
}
const newWidth = Math.max(resizingInitialWidth + dx, minWidth);
const parentWidth = nodeRef?.parentElement?.offsetWidth || 0;
const newWidth = Math.max(resizingInitialWidth + dx, minWidth)
const parentWidth = nodeRef?.parentElement?.offsetWidth || 0
if (newWidth < parentWidth) {
updateAttributes({ width: newWidth });
updateAttributes({ width: newWidth })
}
}
function endResize() {
resizing = false;
resizingInitialMouseX = 0;
resizingInitialWidth = 0;
resizing = false
resizingInitialMouseX = 0
resizingInitialWidth = 0
}
function handleTouchStart(e: TouchEvent, position: 'left' | 'right') {
e.preventDefault();
resizing = true;
resizingPosition = position;
resizingInitialMouseX = e.touches[0].clientX;
if (imgRef) resizingInitialWidth = imgRef.offsetWidth;
e.preventDefault()
resizing = true
resizingPosition = position
resizingInitialMouseX = e.touches[0].clientX
if (imgRef) resizingInitialWidth = imgRef.offsetWidth
}
function handleTouchMove(e: TouchEvent) {
if (!resizing) return;
let dx = e.touches[0].clientX - resizingInitialMouseX;
if (!resizing) return
let dx = e.touches[0].clientX - resizingInitialMouseX
if (resizingPosition === 'left') {
dx = resizingInitialMouseX - e.touches[0].clientX;
dx = resizingInitialMouseX - e.touches[0].clientX
}
const newWidth = Math.max(resizingInitialWidth + dx, minWidth);
const parentWidth = nodeRef?.parentElement?.offsetWidth || 0;
const newWidth = Math.max(resizingInitialWidth + dx, minWidth)
const parentWidth = nodeRef?.parentElement?.offsetWidth || 0
if (newWidth < parentWidth) {
updateAttributes({ width: newWidth });
updateAttributes({ width: newWidth })
}
}
function handleTouchEnd() {
resizing = false;
resizingInitialMouseX = 0;
resizingInitialWidth = 0;
resizing = false
resizingInitialMouseX = 0
resizingInitialWidth = 0
}
onMount(() => {
// Attach id to nodeRef
nodeRef = document.getElementById('resizable-container-media') as HTMLDivElement;
nodeRef = document.getElementById('resizable-container-media') as HTMLDivElement
// Mouse events
window.addEventListener('mousemove', resize);
window.addEventListener('mouseup', endResize);
window.addEventListener('mousemove', resize)
window.addEventListener('mouseup', endResize)
// Touch events
window.addEventListener('touchmove', handleTouchMove);
window.addEventListener('touchend', handleTouchEnd);
});
window.addEventListener('touchmove', handleTouchMove)
window.addEventListener('touchend', handleTouchEnd)
})
onDestroy(() => {
window.removeEventListener('mousemove', resize);
window.removeEventListener('mouseup', endResize);
window.removeEventListener('touchmove', handleTouchMove);
window.removeEventListener('touchend', handleTouchEnd);
});
window.removeEventListener('mousemove', resize)
window.removeEventListener('mouseup', endResize)
window.removeEventListener('touchmove', handleTouchMove)
window.removeEventListener('touchend', handleTouchEnd)
})
</script>
<NodeViewWrapper
@ -133,10 +133,10 @@
aria-label="Resize left"
class="edra-media-resize-handle edra-media-resize-handle-left"
onmousedown={(event: MouseEvent) => {
handleResizingPosition(event, 'left');
handleResizingPosition(event, 'left')
}}
ontouchstart={(event: TouchEvent) => {
handleTouchStart(event, 'left');
handleTouchStart(event, 'left')
}}
>
<div class="edra-media-resize-indicator"></div>
@ -148,10 +148,10 @@
aria-label="Resize right"
class="edra-media-resize-handle edra-media-resize-handle-right"
onmousedown={(event: MouseEvent) => {
handleResizingPosition(event, 'right');
handleResizingPosition(event, 'right')
}}
ontouchstart={(event: TouchEvent) => {
handleTouchStart(event, 'right');
handleTouchStart(event, 'right')
}}
>
<div class="edra-media-resize-indicator"></div>
@ -182,7 +182,7 @@
<button
class="edra-toolbar-button"
onclick={() => {
if (caption === null || caption.trim() === '') caption = 'Image Caption';
if (caption === null || caption.trim() === '') caption = 'Image Caption'
}}
title="Caption"
>
@ -191,7 +191,7 @@
<button
class="edra-toolbar-button"
onclick={() => {
duplicateContent(editor, node);
duplicateContent(editor, node)
}}
title="Duplicate"
>
@ -202,7 +202,7 @@
onclick={() => {
updateAttributes({
width: 'fit-content'
});
})
}}
title="Full Screen"
>
@ -211,7 +211,7 @@
<button
class="edra-toolbar-button edra-destructive"
onclick={() => {
deleteNode();
deleteNode()
}}
title="Delete"
>

View file

@ -1,17 +1,17 @@
<script lang="ts">
import type { NodeViewProps } from '@tiptap/core';
import Image from 'lucide-svelte/icons/image';
import { NodeViewWrapper } from 'svelte-tiptap';
const { editor }: NodeViewProps = $props();
import type { NodeViewProps } from '@tiptap/core'
import Image from 'lucide-svelte/icons/image'
import { NodeViewWrapper } from 'svelte-tiptap'
const { editor }: NodeViewProps = $props()
function handleClick(e: MouseEvent) {
if (!editor.isEditable) return;
e.preventDefault();
const imageUrl = prompt('Enter the URL of an image:');
if (!editor.isEditable) return
e.preventDefault()
const imageUrl = prompt('Enter the URL of an image:')
if (!imageUrl) {
return;
return
}
editor.chain().focus().setImage({ src: imageUrl }).run();
editor.chain().focus().setImage({ src: imageUrl }).run()
}
</script>

View file

@ -1,75 +1,75 @@
<script lang="ts">
import type { Editor } from '@tiptap/core';
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
import ArrowRight from 'lucide-svelte/icons/arrow-right';
import CaseSensitive from 'lucide-svelte/icons/case-sensitive';
import Replace from 'lucide-svelte/icons/replace';
import ReplaceAll from 'lucide-svelte/icons/replace-all';
import Search from 'lucide-svelte/icons/search';
import type { Editor } from '@tiptap/core'
import ArrowLeft from 'lucide-svelte/icons/arrow-left'
import ArrowRight from 'lucide-svelte/icons/arrow-right'
import CaseSensitive from 'lucide-svelte/icons/case-sensitive'
import Replace from 'lucide-svelte/icons/replace'
import ReplaceAll from 'lucide-svelte/icons/replace-all'
import Search from 'lucide-svelte/icons/search'
interface Props {
editor: Editor;
show: boolean;
editor: Editor
show: boolean
}
let { editor, show = $bindable(false) }: Props = $props();
let { editor, show = $bindable(false) }: Props = $props()
let searchText = $state('');
let replaceText = $state('');
let caseSensitive = $state(false);
let searchText = $state('')
let replaceText = $state('')
let caseSensitive = $state(false)
let searchIndex = $derived(editor.storage?.searchAndReplace?.resultIndex);
let searchCount = $derived(editor.storage?.searchAndReplace?.results.length);
let searchIndex = $derived(editor.storage?.searchAndReplace?.resultIndex)
let searchCount = $derived(editor.storage?.searchAndReplace?.results.length)
function updateSearchTerm(clearIndex: boolean = false) {
if (clearIndex) editor.commands.resetIndex();
if (clearIndex) editor.commands.resetIndex()
editor.commands.setSearchTerm(searchText);
editor.commands.setReplaceTerm(replaceText);
editor.commands.setCaseSensitive(caseSensitive);
editor.commands.setSearchTerm(searchText)
editor.commands.setReplaceTerm(replaceText)
editor.commands.setCaseSensitive(caseSensitive)
}
function goToSelection() {
const { results, resultIndex } = editor.storage.searchAndReplace;
const position = results[resultIndex];
if (!position) return;
editor.commands.setTextSelection(position);
const { node } = editor.view.domAtPos(editor.state.selection.anchor);
if (node instanceof HTMLElement) node.scrollIntoView({ behavior: 'smooth', block: 'center' });
const { results, resultIndex } = editor.storage.searchAndReplace
const position = results[resultIndex]
if (!position) return
editor.commands.setTextSelection(position)
const { node } = editor.view.domAtPos(editor.state.selection.anchor)
if (node instanceof HTMLElement) node.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
function replace() {
editor.commands.replace();
goToSelection();
editor.commands.replace()
goToSelection()
}
const next = () => {
editor.commands.nextSearchResult();
goToSelection();
};
editor.commands.nextSearchResult()
goToSelection()
}
const previous = () => {
editor.commands.previousSearchResult();
goToSelection();
};
editor.commands.previousSearchResult()
goToSelection()
}
const clear = () => {
searchText = '';
replaceText = '';
caseSensitive = false;
editor.commands.resetIndex();
};
searchText = ''
replaceText = ''
caseSensitive = false
editor.commands.resetIndex()
}
const replaceAll = () => editor.commands.replaceAll();
const replaceAll = () => editor.commands.replaceAll()
</script>
<div class="edra-search-and-replace">
<button
class="edra-command-button"
onclick={() => {
show = !show;
clear();
updateSearchTerm();
show = !show
clear()
updateSearchTerm()
}}
title={show ? 'Go Back' : 'Search and Replace'}
>
@ -87,8 +87,8 @@
class="edra-command-button"
class:active={caseSensitive}
onclick={() => {
caseSensitive = !caseSensitive;
updateSearchTerm();
caseSensitive = !caseSensitive
updateSearchTerm()
}}
title="Case Sensitive"
>

View file

@ -1,92 +1,92 @@
<script lang="ts">
import { icons } from 'lucide-svelte';
import { icons } from 'lucide-svelte'
interface Props {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
props: Record<string, any>;
props: Record<string, any>
}
const { props }: Props = $props();
const { props }: Props = $props()
let scrollContainer = $state<HTMLElement | null>(null);
let scrollContainer = $state<HTMLElement | null>(null)
let selectedGroupIndex = $state<number>(0);
let selectedCommandIndex = $state<number>(0);
let selectedGroupIndex = $state<number>(0)
let selectedCommandIndex = $state<number>(0)
const items = $derived.by(() => props.items);
const items = $derived.by(() => props.items)
$effect(() => {
if (items) {
selectedGroupIndex = 0;
selectedCommandIndex = 0;
selectedGroupIndex = 0
selectedCommandIndex = 0
}
});
})
$effect(() => {
const activeItem = document.getElementById(`${selectedGroupIndex}-${selectedCommandIndex}`);
const activeItem = document.getElementById(`${selectedGroupIndex}-${selectedCommandIndex}`)
if (activeItem !== null && scrollContainer !== null) {
const offsetTop = activeItem.offsetTop;
const offsetHeight = activeItem.offsetHeight;
scrollContainer.scrollTop = offsetTop - offsetHeight;
const offsetTop = activeItem.offsetTop
const offsetHeight = activeItem.offsetHeight
scrollContainer.scrollTop = offsetTop - offsetHeight
}
});
})
const selectItem = (groupIndex: number, commandIndex: number) => {
const command = props.items[groupIndex].commands[commandIndex];
props.command(command);
};
const command = props.items[groupIndex].commands[commandIndex]
props.command(command)
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'ArrowDown' || ((e.ctrlKey || e.metaKey) && e.key === 'j') || e.key === 'Tab') {
e.preventDefault();
e.preventDefault()
if (!props.items.length) {
return false;
return false
}
const commands = props.items[selectedGroupIndex].commands;
let newCommandIndex = selectedCommandIndex + 1;
let newGroupIndex = selectedGroupIndex;
const commands = props.items[selectedGroupIndex].commands
let newCommandIndex = selectedCommandIndex + 1
let newGroupIndex = selectedGroupIndex
if (commands.length - 1 < newCommandIndex) {
newCommandIndex = 0;
newGroupIndex = selectedGroupIndex + 1;
newCommandIndex = 0
newGroupIndex = selectedGroupIndex + 1
}
if (props.items.length - 1 < newGroupIndex) {
newGroupIndex = 0;
newGroupIndex = 0
}
selectedCommandIndex = newCommandIndex;
selectedGroupIndex = newGroupIndex;
return true;
selectedCommandIndex = newCommandIndex
selectedGroupIndex = newGroupIndex
return true
}
if (e.key === 'ArrowUp' || ((e.ctrlKey || e.metaKey) && e.key === 'k')) {
e.preventDefault();
e.preventDefault()
if (!props.items.length) {
return false;
return false
}
let newCommandIndex = selectedCommandIndex - 1;
let newGroupIndex = selectedGroupIndex;
let newCommandIndex = selectedCommandIndex - 1
let newGroupIndex = selectedGroupIndex
if (newCommandIndex < 0) {
newGroupIndex = selectedGroupIndex - 1;
newCommandIndex = props.items[newGroupIndex]?.commands.length - 1 || 0;
newGroupIndex = selectedGroupIndex - 1
newCommandIndex = props.items[newGroupIndex]?.commands.length - 1 || 0
}
if (newGroupIndex < 0) {
newGroupIndex = props.items.length - 1;
newCommandIndex = props.items[newGroupIndex].commands.length - 1;
newGroupIndex = props.items.length - 1
newCommandIndex = props.items[newGroupIndex].commands.length - 1
}
selectedCommandIndex = newCommandIndex;
selectedGroupIndex = newGroupIndex;
return true;
selectedCommandIndex = newCommandIndex
selectedGroupIndex = newGroupIndex
return true
}
if (e.key === 'Enter') {
e.preventDefault();
e.preventDefault()
if (!props.items.length || selectedGroupIndex === -1 || selectedCommandIndex === -1) {
return false;
return false
}
selectItem(selectedGroupIndex, selectedCommandIndex);
return true;
selectItem(selectedGroupIndex, selectedCommandIndex)
return true
}
return false;
return false
}
</script>

View file

@ -1,112 +1,112 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { NodeViewWrapper } from 'svelte-tiptap';
import type { NodeViewProps } from '@tiptap/core';
import { onDestroy, onMount } from 'svelte'
import { NodeViewWrapper } from 'svelte-tiptap'
import type { NodeViewProps } from '@tiptap/core'
import AlignLeft from 'lucide-svelte/icons/align-left';
import AlignCenter from 'lucide-svelte/icons/align-center';
import AlignRight from 'lucide-svelte/icons/align-right';
import CopyIcon from 'lucide-svelte/icons/copy';
import Fullscreen from 'lucide-svelte/icons/fullscreen';
import Trash from 'lucide-svelte/icons/trash';
import Captions from 'lucide-svelte/icons/captions';
import AlignLeft from 'lucide-svelte/icons/align-left'
import AlignCenter from 'lucide-svelte/icons/align-center'
import AlignRight from 'lucide-svelte/icons/align-right'
import CopyIcon from 'lucide-svelte/icons/copy'
import Fullscreen from 'lucide-svelte/icons/fullscreen'
import Trash from 'lucide-svelte/icons/trash'
import Captions from 'lucide-svelte/icons/captions'
import { duplicateContent } from '../../utils.js';
import { duplicateContent } from '../../utils.js'
const { node, editor, selected, deleteNode, updateAttributes }: NodeViewProps = $props();
const { node, editor, selected, deleteNode, updateAttributes }: NodeViewProps = $props()
const minWidth = 150;
const minWidth = 150
let vidRef: HTMLVideoElement;
let nodeRef: HTMLDivElement;
let vidRef: HTMLVideoElement
let nodeRef: HTMLDivElement
let caption: string | null = $state(node.attrs.title);
let caption: string | null = $state(node.attrs.title)
$effect(() => {
if (caption?.trim() === '') caption = null;
updateAttributes({ title: caption });
});
if (caption?.trim() === '') caption = null
updateAttributes({ title: caption })
})
let resizing = $state(false);
let resizingInitialWidth = $state(0);
let resizingInitialMouseX = $state(0);
let resizingPosition = $state<'left' | 'right'>('left');
let resizing = $state(false)
let resizingInitialWidth = $state(0)
let resizingInitialMouseX = $state(0)
let resizingPosition = $state<'left' | 'right'>('left')
function handleResizingPosition(e: MouseEvent, position: 'left' | 'right') {
startResize(e);
resizingPosition = position;
startResize(e)
resizingPosition = position
}
function startResize(e: MouseEvent) {
e.preventDefault();
resizing = true;
resizingInitialMouseX = e.clientX;
if (vidRef) resizingInitialWidth = vidRef.offsetWidth;
e.preventDefault()
resizing = true
resizingInitialMouseX = e.clientX
if (vidRef) resizingInitialWidth = vidRef.offsetWidth
}
function resize(e: MouseEvent) {
if (!resizing) return;
let dx = e.clientX - resizingInitialMouseX;
if (!resizing) return
let dx = e.clientX - resizingInitialMouseX
if (resizingPosition === 'left') {
dx = resizingInitialMouseX - e.clientX;
dx = resizingInitialMouseX - e.clientX
}
const newWidth = Math.max(resizingInitialWidth + dx, minWidth);
const parentWidth = nodeRef?.parentElement?.offsetWidth || 0;
const newWidth = Math.max(resizingInitialWidth + dx, minWidth)
const parentWidth = nodeRef?.parentElement?.offsetWidth || 0
if (newWidth < parentWidth) {
updateAttributes({ width: newWidth });
updateAttributes({ width: newWidth })
}
}
function endResize() {
resizing = false;
resizingInitialMouseX = 0;
resizingInitialWidth = 0;
resizing = false
resizingInitialMouseX = 0
resizingInitialWidth = 0
}
function handleTouchStart(e: TouchEvent, position: 'left' | 'right') {
e.preventDefault();
resizing = true;
resizingPosition = position;
resizingInitialMouseX = e.touches[0].clientX;
if (vidRef) resizingInitialWidth = vidRef.offsetWidth;
e.preventDefault()
resizing = true
resizingPosition = position
resizingInitialMouseX = e.touches[0].clientX
if (vidRef) resizingInitialWidth = vidRef.offsetWidth
}
function handleTouchMove(e: TouchEvent) {
if (!resizing) return;
let dx = e.touches[0].clientX - resizingInitialMouseX;
if (!resizing) return
let dx = e.touches[0].clientX - resizingInitialMouseX
if (resizingPosition === 'left') {
dx = resizingInitialMouseX - e.touches[0].clientX;
dx = resizingInitialMouseX - e.touches[0].clientX
}
const newWidth = Math.max(resizingInitialWidth + dx, minWidth);
const parentWidth = nodeRef?.parentElement?.offsetWidth || 0;
const newWidth = Math.max(resizingInitialWidth + dx, minWidth)
const parentWidth = nodeRef?.parentElement?.offsetWidth || 0
if (newWidth < parentWidth) {
updateAttributes({ width: newWidth });
updateAttributes({ width: newWidth })
}
}
function handleTouchEnd() {
resizing = false;
resizingInitialMouseX = 0;
resizingInitialWidth = 0;
resizing = false
resizingInitialMouseX = 0
resizingInitialWidth = 0
}
onMount(() => {
// Attach id to nodeRef
nodeRef = document.getElementById('resizable-container-media') as HTMLDivElement;
nodeRef = document.getElementById('resizable-container-media') as HTMLDivElement
// Mouse events
window.addEventListener('mousemove', resize);
window.addEventListener('mouseup', endResize);
window.addEventListener('mousemove', resize)
window.addEventListener('mouseup', endResize)
// Touch events
window.addEventListener('touchmove', handleTouchMove);
window.addEventListener('touchend', handleTouchEnd);
});
window.addEventListener('touchmove', handleTouchMove)
window.addEventListener('touchend', handleTouchEnd)
})
onDestroy(() => {
window.removeEventListener('mousemove', resize);
window.removeEventListener('mouseup', endResize);
window.removeEventListener('touchmove', handleTouchMove);
window.removeEventListener('touchend', handleTouchEnd);
});
window.removeEventListener('mousemove', resize)
window.removeEventListener('mouseup', endResize)
window.removeEventListener('touchmove', handleTouchMove)
window.removeEventListener('touchend', handleTouchEnd)
})
</script>
<NodeViewWrapper
@ -136,10 +136,10 @@
aria-label="Resize left"
class="edra-media-resize-handle edra-media-resize-handle-left"
onmousedown={(event: MouseEvent) => {
handleResizingPosition(event, 'left');
handleResizingPosition(event, 'left')
}}
ontouchstart={(event: TouchEvent) => {
handleTouchStart(event, 'left');
handleTouchStart(event, 'left')
}}
>
<div class="edra-media-resize-indicator"></div>
@ -151,10 +151,10 @@
aria-label="Resize right"
class="edra-media-resize-handle edra-media-resize-handle-right"
onmousedown={(event: MouseEvent) => {
handleResizingPosition(event, 'right');
handleResizingPosition(event, 'right')
}}
ontouchstart={(event: TouchEvent) => {
handleTouchStart(event, 'right');
handleTouchStart(event, 'right')
}}
>
<div class="edra-media-resize-indicator"></div>
@ -185,7 +185,7 @@
<button
class="edra-toolbar-button"
onclick={() => {
if (caption === null || caption.trim() === '') caption = 'Video Caption';
if (caption === null || caption.trim() === '') caption = 'Video Caption'
}}
title="Caption"
>
@ -194,7 +194,7 @@
<button
class="edra-toolbar-button"
onclick={() => {
duplicateContent(editor, node);
duplicateContent(editor, node)
}}
title="Duplicate"
>
@ -205,7 +205,7 @@
onclick={() => {
updateAttributes({
width: 'fit-content'
});
})
}}
title="Full Screen"
>
@ -214,7 +214,7 @@
<button
class="edra-toolbar-button edra-destructive"
onclick={() => {
deleteNode();
deleteNode()
}}
title="Delete"
>

View file

@ -1,17 +1,17 @@
<script lang="ts">
import type { NodeViewProps } from '@tiptap/core';
import Video from 'lucide-svelte/icons/video';
import { NodeViewWrapper } from 'svelte-tiptap';
const { editor }: NodeViewProps = $props();
import type { NodeViewProps } from '@tiptap/core'
import Video from 'lucide-svelte/icons/video'
import { NodeViewWrapper } from 'svelte-tiptap'
const { editor }: NodeViewProps = $props()
function handleClick(e: MouseEvent) {
if (!editor.isEditable) return;
e.preventDefault();
const videoUrl = prompt('Enter the URL of the video:');
if (!editor.isEditable) return
e.preventDefault()
const videoUrl = prompt('Enter the URL of the video:')
if (!videoUrl) {
return;
return
}
editor.chain().focus().setVideo(videoUrl).run();
editor.chain().focus().setVideo(videoUrl).run()
}
</script>

View file

@ -1,43 +1,43 @@
<script lang="ts">
import { type Editor } from '@tiptap/core';
import { onMount } from 'svelte';
import { type Editor } from '@tiptap/core'
import { onMount } from 'svelte'
import { initiateEditor } from '../editor.js';
import './style.css';
import 'katex/dist/katex.min.css';
import { initiateEditor } from '../editor.js'
import './style.css'
import 'katex/dist/katex.min.css'
// Lowlight
import { CodeBlockLowlight } from '@tiptap/extension-code-block-lowlight';
import { all, createLowlight } from 'lowlight';
import '../editor.css';
import '../onedark.css';
import { SvelteNodeViewRenderer } from 'svelte-tiptap';
import CodeExtended from './components/CodeExtended.svelte';
import { AudioPlaceholder } from '../extensions/audio/AudioPlaceholder.js';
import AudioPlaceholderComponent from './components/AudioPlaceholder.svelte';
import AudioExtendedComponent from './components/AudioExtended.svelte';
import { ImagePlaceholder } from '../extensions/image/ImagePlaceholder.js';
import ImagePlaceholderComponent from './components/ImagePlaceholder.svelte';
import { VideoPlaceholder } from '../extensions/video/VideoPlaceholder.js';
import VideoPlaceholderComponent from './components/VideoPlaceholder.svelte';
import { ImageExtended } from '../extensions/image/ImageExtended.js';
import ImageExtendedComponent from './components/ImageExtended.svelte';
import VideoExtendedComponent from './components/VideoExtended.svelte';
import { VideoExtended } from '../extensions/video/VideoExtended.js';
import { AudioExtended } from '../extensions/audio/AudiExtended.js';
import LinkMenu from './menus/link-menu.svelte';
import TableRowMenu from './menus/table/table-row-menu.svelte';
import TableColMenu from './menus/table/table-col-menu.svelte';
import slashcommand from '../extensions/slash-command/slashcommand.js';
import SlashCommandList from './components/SlashCommandList.svelte';
import LoaderCircle from 'lucide-svelte/icons/loader-circle';
import { focusEditor, type EdraProps } from '../utils.js';
import IFramePlaceholderComponent from './components/IFramePlaceholder.svelte';
import { IFramePlaceholder } from '../extensions/iframe/IFramePlaceholder.js';
import { IFrameExtended } from '../extensions/iframe/IFrameExtended.js';
import IFrameExtendedComponent from './components/IFrameExtended.svelte';
import { CodeBlockLowlight } from '@tiptap/extension-code-block-lowlight'
import { all, createLowlight } from 'lowlight'
import '../editor.css'
import '../onedark.css'
import { SvelteNodeViewRenderer } from 'svelte-tiptap'
import CodeExtended from './components/CodeExtended.svelte'
import { AudioPlaceholder } from '../extensions/audio/AudioPlaceholder.js'
import AudioPlaceholderComponent from './components/AudioPlaceholder.svelte'
import AudioExtendedComponent from './components/AudioExtended.svelte'
import { ImagePlaceholder } from '../extensions/image/ImagePlaceholder.js'
import ImagePlaceholderComponent from './components/ImagePlaceholder.svelte'
import { VideoPlaceholder } from '../extensions/video/VideoPlaceholder.js'
import VideoPlaceholderComponent from './components/VideoPlaceholder.svelte'
import { ImageExtended } from '../extensions/image/ImageExtended.js'
import ImageExtendedComponent from './components/ImageExtended.svelte'
import VideoExtendedComponent from './components/VideoExtended.svelte'
import { VideoExtended } from '../extensions/video/VideoExtended.js'
import { AudioExtended } from '../extensions/audio/AudiExtended.js'
import LinkMenu from './menus/link-menu.svelte'
import TableRowMenu from './menus/table/table-row-menu.svelte'
import TableColMenu from './menus/table/table-col-menu.svelte'
import slashcommand from '../extensions/slash-command/slashcommand.js'
import SlashCommandList from './components/SlashCommandList.svelte'
import LoaderCircle from 'lucide-svelte/icons/loader-circle'
import { focusEditor, type EdraProps } from '../utils.js'
import IFramePlaceholderComponent from './components/IFramePlaceholder.svelte'
import { IFramePlaceholder } from '../extensions/iframe/IFramePlaceholder.js'
import { IFrameExtended } from '../extensions/iframe/IFrameExtended.js'
import IFrameExtendedComponent from './components/IFrameExtended.svelte'
const lowlight = createLowlight(all);
const lowlight = createLowlight(all)
let {
class: className = '',
@ -50,9 +50,9 @@
showTableBubbleMenu = true,
onUpdate,
children
}: EdraProps = $props();
}: EdraProps = $props()
let element = $state<HTMLElement>();
let element = $state<HTMLElement>()
onMount(() => {
editor = initiateEditor(
@ -64,7 +64,7 @@
lowlight
}).extend({
addNodeView() {
return SvelteNodeViewRenderer(CodeExtended);
return SvelteNodeViewRenderer(CodeExtended)
}
}),
AudioPlaceholder(AudioPlaceholderComponent),
@ -81,13 +81,13 @@
editable,
onUpdate,
onTransaction: (props) => {
editor = undefined;
editor = props.editor;
editor = undefined
editor = props.editor
}
}
);
return () => editor?.destroy();
});
)
return () => editor?.destroy()
})
</script>
<div class={`edra ${className}`}>
@ -113,7 +113,7 @@
onclick={(event) => focusEditor(editor, event)}
onkeydown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
focusEditor(editor, event);
focusEditor(editor, event)
}
}}
class="edra-editor"

View file

@ -1,3 +1,3 @@
export { default as Edra } from './editor.svelte';
export { default as EdraToolbar } from './toolbar.svelte';
export { default as EdraBubbleMenu } from './menus/bubble-menu.svelte';
export { default as Edra } from './editor.svelte'
export { default as EdraToolbar } from './toolbar.svelte'
export { default as EdraBubbleMenu } from './menus/bubble-menu.svelte'

View file

@ -1,89 +1,89 @@
<script lang="ts">
import { BubbleMenu } from 'svelte-tiptap';
import { isTextSelection, type Editor } from '@tiptap/core';
import { commands } from '../../commands/commands.js';
import EdraToolBarIcon from '../components/EdraToolBarIcon.svelte';
import type { ShouldShowProps } from '../../utils.js';
import type { Snippet } from 'svelte';
import { BubbleMenu } from 'svelte-tiptap'
import { isTextSelection, type Editor } from '@tiptap/core'
import { commands } from '../../commands/commands.js'
import EdraToolBarIcon from '../components/EdraToolBarIcon.svelte'
import type { ShouldShowProps } from '../../utils.js'
import type { Snippet } from 'svelte'
interface Props {
class?: string;
editor: Editor;
children?: Snippet<[]>;
class?: string
editor: Editor
children?: Snippet<[]>
}
const { class: className = '', editor, children }: Props = $props();
const { class: className = '', editor, children }: Props = $props()
let isDragging = $state(false);
let isDragging = $state(false)
editor.view.dom.addEventListener('dragstart', () => {
isDragging = true;
});
isDragging = true
})
editor.view.dom.addEventListener('drop', () => {
isDragging = true;
isDragging = true
// Allow some time for the drop action to complete before re-enabling
setTimeout(() => {
isDragging = false;
}, 100); // Adjust delay if needed
});
isDragging = false
}, 100) // Adjust delay if needed
})
const bubbleMenuCommands = [
...commands['text-formatting'].commands,
...commands.alignment.commands,
...commands.lists.commands
];
]
const colorCommands = commands.colors.commands;
const fontCommands = commands.fonts.commands;
const colorCommands = commands.colors.commands
const fontCommands = commands.fonts.commands
function shouldShow(props: ShouldShowProps) {
if (!props.editor.isEditable) return false;
const { view, editor } = props;
if (!props.editor.isEditable) return false
const { view, editor } = props
if (!view || editor.view.dragging) {
return false;
return false
}
if (editor.isActive('link')) return false;
if (editor.isActive('codeBlock')) return false;
if (editor.isActive('link')) return false
if (editor.isActive('codeBlock')) return false
const {
state: {
doc,
selection,
selection: { empty, from, to }
}
} = editor;
} = editor
// check if the selection is a table grip
const domAtPos = view.domAtPos(from || 0).node as HTMLElement;
const nodeDOM = view.nodeDOM(from || 0) as HTMLElement;
const node = nodeDOM || domAtPos;
const domAtPos = view.domAtPos(from || 0).node as HTMLElement
const nodeDOM = view.nodeDOM(from || 0) as HTMLElement
const node = nodeDOM || domAtPos
if (isTableGripSelected(node)) {
return false;
return false
}
// Sometime check for `empty` is not enough.
// Doubleclick an empty paragraph returns a node size of 2.
// So we check also for an empty text size.
const isEmptyTextBlock = !doc.textBetween(from, to).length && isTextSelection(selection);
const isEmptyTextBlock = !doc.textBetween(from, to).length && isTextSelection(selection)
if (empty || isEmptyTextBlock || !editor.isEditable) {
return false;
return false
}
return !isDragging && !editor.state.selection.empty;
return !isDragging && !editor.state.selection.empty
}
const isTableGripSelected = (node: HTMLElement) => {
let container = node;
let container = node
while (container && !['TD', 'TH'].includes(container.tagName)) {
container = container.parentElement!;
container = container.parentElement!
}
const gripColumn =
container && container.querySelector && container.querySelector('a.grip-column.selected');
container && container.querySelector && container.querySelector('a.grip-column.selected')
const gripRow =
container && container.querySelector && container.querySelector('a.grip-row.selected');
container && container.querySelector && container.querySelector('a.grip-row.selected')
if (gripColumn || gripRow) {
return true;
return true
}
return false
}
return false;
};
</script>
<BubbleMenu
@ -130,14 +130,14 @@
{editor}
style={`color: ${editor.getAttributes('textStyle').color};`}
onclick={() => {
const color = editor.getAttributes('textStyle').color;
const hasColor = editor.isActive('textStyle', { color });
const color = editor.getAttributes('textStyle').color
const hasColor = editor.isActive('textStyle', { color })
if (hasColor) {
editor.chain().focus().unsetColor().run();
editor.chain().focus().unsetColor().run()
} else {
const color = prompt('Enter the color of the text:');
const color = prompt('Enter the color of the text:')
if (color !== null) {
editor.chain().focus().setColor(color).run();
editor.chain().focus().setColor(color).run()
}
}
}}
@ -147,13 +147,13 @@
{editor}
style={`background-color: ${editor.getAttributes('highlight').color};`}
onclick={() => {
const hasHightlight = editor.isActive('highlight');
const hasHightlight = editor.isActive('highlight')
if (hasHightlight) {
editor.chain().focus().unsetHighlight().run();
editor.chain().focus().unsetHighlight().run()
} else {
const color = prompt('Enter the color of the highlight:');
const color = prompt('Enter the color of the highlight:')
if (color !== null) {
editor.chain().focus().setHighlight({ color }).run();
editor.chain().focus().setHighlight({ color }).run()
}
}
}}

View file

@ -1,36 +1,36 @@
<script lang="ts">
import { type Editor } from '@tiptap/core';
import { BubbleMenu } from 'svelte-tiptap';
import type { ShouldShowProps } from '../../utils.js';
import Copy from 'lucide-svelte/icons/copy';
import Trash from 'lucide-svelte/icons/trash';
import Edit from 'lucide-svelte/icons/pen';
import Check from 'lucide-svelte/icons/check';
import { type Editor } from '@tiptap/core'
import { BubbleMenu } from 'svelte-tiptap'
import type { ShouldShowProps } from '../../utils.js'
import Copy from 'lucide-svelte/icons/copy'
import Trash from 'lucide-svelte/icons/trash'
import Edit from 'lucide-svelte/icons/pen'
import Check from 'lucide-svelte/icons/check'
interface Props {
editor: Editor;
editor: Editor
}
let { editor }: Props = $props();
let { editor }: Props = $props()
const link = $derived.by(() => editor.getAttributes('link').href);
const link = $derived.by(() => editor.getAttributes('link').href)
let isEditing = $state(false);
let isEditing = $state(false)
function setLink(url: string) {
if (url.trim() === '') {
editor.chain().focus().extendMarkRange('link').unsetLink().run();
return;
editor.chain().focus().extendMarkRange('link').unsetLink().run()
return
}
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run()
}
let linkInput = $state('');
let isLinkValid = $state(true);
let linkInput = $state('')
let isLinkValid = $state(true)
$effect(() => {
isLinkValid = validateURL(linkInput);
});
isLinkValid = validateURL(linkInput)
})
function validateURL(url: string): boolean {
const urlPattern = new RegExp(
@ -41,8 +41,8 @@
'(\\?[;&a-zA-Z\\d%_.~+=-]*)?' + // query string
'(\\#[-a-zA-Z\\d_]*)?$', // fragment locator
'i'
);
return urlPattern.test(url);
)
return urlPattern.test(url)
}
</script>
@ -50,14 +50,14 @@
{editor}
pluginKey="link-menu"
shouldShow={(props: ShouldShowProps) => {
if (!props.editor.isEditable) return false;
if (!props.editor.isEditable) return false
if (props.editor.isActive('link')) {
return true;
return true
} else {
isEditing = false;
linkInput = '';
isLinkValid = true;
return false;
isEditing = false
linkInput = ''
isLinkValid = true
return false
}
}}
class="bubble-menu-wrapper"
@ -79,8 +79,8 @@
<button
class="edra-command-button"
onclick={() => {
linkInput = link;
isEditing = true;
linkInput = link
isEditing = true
}}
title="Edit the URL"
>
@ -89,7 +89,7 @@
<button
class="edra-command-button"
onclick={() => {
navigator.clipboard.writeText(link);
navigator.clipboard.writeText(link)
}}
title="Copy the URL to the clipboard"
>
@ -98,7 +98,7 @@
<button
class="edra-command-button"
onclick={() => {
editor.chain().focus().extendMarkRange('link').unsetLink().run();
editor.chain().focus().extendMarkRange('link').unsetLink().run()
}}
title="Remove the link"
>
@ -108,9 +108,9 @@
<button
class="edra-command-button"
onclick={() => {
isEditing = false;
editor.commands.focus();
setLink(linkInput);
isEditing = false
editor.commands.focus()
setLink(linkInput)
}}
disabled={!isLinkValid}
>

View file

@ -1,32 +1,32 @@
<script lang="ts">
import type { ShouldShowProps } from '../../../utils.js';
import { type Editor } from '@tiptap/core';
import { BubbleMenu } from 'svelte-tiptap';
import ArrowLeftFromLine from 'lucide-svelte/icons/arrow-left-from-line';
import ArrowRightFromLine from 'lucide-svelte/icons/arrow-right-from-line';
import Trash from 'lucide-svelte/icons/trash';
import { isColumnGripSelected } from '../../../extensions/table/utils.js';
import type { ShouldShowProps } from '../../../utils.js'
import { type Editor } from '@tiptap/core'
import { BubbleMenu } from 'svelte-tiptap'
import ArrowLeftFromLine from 'lucide-svelte/icons/arrow-left-from-line'
import ArrowRightFromLine from 'lucide-svelte/icons/arrow-right-from-line'
import Trash from 'lucide-svelte/icons/trash'
import { isColumnGripSelected } from '../../../extensions/table/utils.js'
interface Props {
editor: Editor;
editor: Editor
}
let { editor }: Props = $props();
let { editor }: Props = $props()
</script>
<BubbleMenu
{editor}
pluginKey="table-col-menu"
shouldShow={(props: ShouldShowProps) => {
if (!props.editor.isEditable) return false;
if (!props.editor.isEditable) return false
if (!props.state) {
return false;
return false
}
return isColumnGripSelected({
editor: props.editor,
view: props.view,
state: props.state,
from: props.from
});
})
}}
class="edra-menu-wrapper"
>

View file

@ -1,32 +1,32 @@
<script lang="ts">
import type { ShouldShowProps } from '../../../utils.js';
import { type Editor } from '@tiptap/core';
import { BubbleMenu } from 'svelte-tiptap';
import ArrowDownFromLine from 'lucide-svelte/icons/arrow-down-from-line';
import ArrowUpFromLine from 'lucide-svelte/icons/arrow-up-from-line';
import Trash from 'lucide-svelte/icons/trash';
import { isRowGripSelected } from '../../../extensions/table/utils.js';
import type { ShouldShowProps } from '../../../utils.js'
import { type Editor } from '@tiptap/core'
import { BubbleMenu } from 'svelte-tiptap'
import ArrowDownFromLine from 'lucide-svelte/icons/arrow-down-from-line'
import ArrowUpFromLine from 'lucide-svelte/icons/arrow-up-from-line'
import Trash from 'lucide-svelte/icons/trash'
import { isRowGripSelected } from '../../../extensions/table/utils.js'
interface Props {
editor: Editor;
editor: Editor
}
let { editor }: Props = $props();
let { editor }: Props = $props()
</script>
<BubbleMenu
{editor}
pluginKey="table-row-menu"
shouldShow={(props: ShouldShowProps) => {
if (!props.editor.isEditable) return false;
if (!props.editor.isEditable) return false
if (!props.state) {
return false;
return false
}
return isRowGripSelected({
editor: props.editor,
view: props.view,
state: props.state,
from: props.from
});
})
}}
class="edra-menu-wrapper"
>

View file

@ -1,23 +1,23 @@
<script lang="ts">
import type { Editor } from '@tiptap/core';
import { commands } from '../commands/commands.js';
import EdraToolBarIcon from './components/EdraToolBarIcon.svelte';
import SearchAndReplace from './components/SearchAndReplace.svelte';
import type { Snippet } from 'svelte';
import type { Editor } from '@tiptap/core'
import { commands } from '../commands/commands.js'
import EdraToolBarIcon from './components/EdraToolBarIcon.svelte'
import SearchAndReplace from './components/SearchAndReplace.svelte'
import type { Snippet } from 'svelte'
interface Props {
class?: string;
editor: Editor;
children?: Snippet<[]>;
class?: string
editor: Editor
children?: Snippet<[]>
}
const { class: className = '', editor, children }: Props = $props();
const { class: className = '', editor, children }: Props = $props()
// Special components that are handled separately
let showSearchAndReplace = $state(false);
const colorCommands = commands.colors.commands;
const fontCommands = commands.fonts.commands;
const excludedCommands = ['colors', 'fonts'];
let showSearchAndReplace = $state(false)
const colorCommands = commands.colors.commands
const fontCommands = commands.fonts.commands
const excludedCommands = ['colors', 'fonts']
</script>
<div class={`edra-toolbar ${className}`}>
@ -44,14 +44,14 @@
{editor}
style={`color: ${editor.getAttributes('textStyle').color};`}
onclick={() => {
const color = editor.getAttributes('textStyle').color;
const hasColor = editor.isActive('textStyle', { color });
const color = editor.getAttributes('textStyle').color
const hasColor = editor.isActive('textStyle', { color })
if (hasColor) {
editor.chain().focus().unsetColor().run();
editor.chain().focus().unsetColor().run()
} else {
const color = prompt('Enter the color of the text:');
const color = prompt('Enter the color of the text:')
if (color !== null) {
editor.chain().focus().setColor(color).run();
editor.chain().focus().setColor(color).run()
}
}
}}
@ -61,13 +61,13 @@
{editor}
style={`background-color: ${editor.getAttributes('highlight').color};`}
onclick={() => {
const hasHightlight = editor.isActive('highlight');
const hasHightlight = editor.isActive('highlight')
if (hasHightlight) {
editor.chain().focus().unsetHighlight().run();
editor.chain().focus().unsetHighlight().run()
} else {
const color = prompt('Enter the color of the highlight:');
const color = prompt('Enter the color of the highlight:')
if (color !== null) {
editor.chain().focus().setHighlight({ color }).run();
editor.chain().focus().setHighlight({ color }).run()
}
}
}}

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