diff --git a/README.md b/README.md
index 8c4f5ff..f473ece 100644
--- a/README.md
+++ b/README.md
@@ -73,19 +73,21 @@ npm run db:backup:sync
### Prerequisites
1. PostgreSQL client tools must be installed (`pg_dump`, `psql`)
+
```bash
# macOS
brew install postgresql
-
+
# Ubuntu/Debian
sudo apt-get install postgresql-client
```
2. Set environment variables in `.env` or `.env.local`:
+
```bash
# Required for local database operations
DATABASE_URL="postgresql://user:password@localhost:5432/dbname"
-
+
# Required for remote database operations (use one of these)
REMOTE_DATABASE_URL="postgresql://user:password@remote-host:5432/dbname"
DATABASE_URL_PRODUCTION="postgresql://user:password@remote-host:5432/dbname"
@@ -123,18 +125,23 @@ npm run db:restore ./backups/backup_file.sql.gz remote
### Common Workflows
#### Daily Development
+
Start your day by syncing the production database to local:
+
```bash
npm run db:backup:sync
```
#### Before Deploying Schema Changes
+
Always backup the remote database:
+
```bash
npm run db:backup:remote
```
#### Recover from Mistakes
+
```bash
# See available backups
npm run db:backups
@@ -146,6 +153,7 @@ npm run db:restore ./backups/local_20240615_143022.sql.gz
### Backup Storage
All backups are stored in `./backups/` with timestamps:
+
- Local: `local_YYYYMMDD_HHMMSS.sql.gz`
- Remote: `remote_YYYYMMDD_HHMMSS.sql.gz`
diff --git a/package-lock.json b/package-lock.json
index 1e4f6c8..a0fb424 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,6 +9,7 @@
"version": "0.0.1",
"dependencies": {
"@aarkue/tiptap-math-extension": "^1.3.6",
+ "@floating-ui/dom": "^1.7.1",
"@prisma/client": "^6.8.2",
"@sveltejs/adapter-node": "^5.2.0",
"@tiptap/core": "^2.12.0",
@@ -677,6 +678,28 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
+ "node_modules/@floating-ui/core": {
+ "version": "1.7.1",
+ "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.1.tgz",
+ "integrity": "sha512-azI0DrjMMfIug/ExbBaeDVJXcY0a7EPvPjb2xAJPa4HeimBX+Z18HK8QQR3jb6356SnDDdxx+hinMLcJEDdOjw==",
+ "dependencies": {
+ "@floating-ui/utils": "^0.2.9"
+ }
+ },
+ "node_modules/@floating-ui/dom": {
+ "version": "1.7.1",
+ "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.1.tgz",
+ "integrity": "sha512-cwsmW/zyw5ltYTUeeYJ60CnQuPqmGwuGVhG9w0PRaRKkAyi38BT5CKrpIbb+jtahSwUl04cWzSx9ZOIxeS6RsQ==",
+ "dependencies": {
+ "@floating-ui/core": "^1.7.1",
+ "@floating-ui/utils": "^0.2.9"
+ }
+ },
+ "node_modules/@floating-ui/utils": {
+ "version": "0.2.9",
+ "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz",
+ "integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg=="
+ },
"node_modules/@humanwhocodes/module-importer": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
diff --git a/package.json b/package.json
index 061d609..4b5ba9f 100644
--- a/package.json
+++ b/package.json
@@ -59,6 +59,7 @@
"type": "module",
"dependencies": {
"@aarkue/tiptap-math-extension": "^1.3.6",
+ "@floating-ui/dom": "^1.7.1",
"@prisma/client": "^6.8.2",
"@sveltejs/adapter-node": "^5.2.0",
"@tiptap/core": "^2.12.0",
diff --git a/src/assets/icons/chevron-right.svg b/src/assets/icons/chevron-right.svg
new file mode 100644
index 0000000..e2ca85b
--- /dev/null
+++ b/src/assets/icons/chevron-right.svg
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/src/assets/icons/drag-handle.svg b/src/assets/icons/drag-handle.svg
new file mode 100644
index 0000000..04cd756
--- /dev/null
+++ b/src/assets/icons/drag-handle.svg
@@ -0,0 +1,8 @@
+
\ No newline at end of file
diff --git a/src/lib/components/admin/AlbumForm.svelte b/src/lib/components/admin/AlbumForm.svelte
index 10f165c..ce9111d 100644
--- a/src/lib/components/admin/AlbumForm.svelte
+++ b/src/lib/components/admin/AlbumForm.svelte
@@ -464,7 +464,6 @@
color: $grey-40;
}
-
.form-section {
display: flex;
flex-direction: column;
diff --git a/src/lib/components/admin/DropdownMenu.svelte b/src/lib/components/admin/DropdownMenu.svelte
index d834927..59ba38f 100644
--- a/src/lib/components/admin/DropdownMenu.svelte
+++ b/src/lib/components/admin/DropdownMenu.svelte
@@ -1,86 +1,207 @@
{#if isOpen && browser}
@@ -90,7 +211,6 @@
@import '$styles/variables.scss';
.dropdown-menu {
- position: fixed;
background: white;
border: 1px solid $grey-85;
border-radius: $unit;
@@ -98,6 +218,8 @@
overflow: hidden;
min-width: 180px;
z-index: 1050;
+ max-height: 400px;
+ overflow-y: auto;
}
.dropdown-item {
@@ -110,7 +232,9 @@
color: $grey-20;
cursor: pointer;
transition: background-color 0.2s ease;
- display: block;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
&:hover {
background-color: $grey-95;
@@ -119,6 +243,38 @@
&.danger {
color: $red-60;
}
+
+ &.has-children {
+ padding-right: $unit-2x;
+ }
+ }
+
+ .item-label {
+ flex: 1;
+ }
+
+ .submenu-icon {
+ width: 16px;
+ height: 16px;
+ margin-left: $unit;
+ color: $grey-40;
+ flex-shrink: 0;
+ display: inline-flex;
+ align-items: center;
+
+ :global(svg) {
+ width: 100%;
+ height: 100%;
+ fill: none;
+ }
+
+ :global(path) {
+ fill: none;
+ stroke: currentColor;
+ stroke-width: 2;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ }
}
.dropdown-divider {
diff --git a/src/lib/components/admin/EnhancedComposer.svelte b/src/lib/components/admin/EnhancedComposer.svelte
index 2c3851e..418ce83 100644
--- a/src/lib/components/admin/EnhancedComposer.svelte
+++ b/src/lib/components/admin/EnhancedComposer.svelte
@@ -25,6 +25,7 @@
import LinkContextMenuComponent from '$lib/components/edra/headless/components/LinkContextMenu.svelte'
import LinkEditDialog from '$lib/components/edra/headless/components/LinkEditDialog.svelte'
import UnifiedMediaModal from './UnifiedMediaModal.svelte'
+ import DragHandle from '$lib/components/edra/drag-handle.svelte'
import { mediaSelectionStore } from '$lib/stores/media-selection'
import type { Media } from '@prisma/client'
@@ -629,7 +630,6 @@
return () => editor?.destroy()
})
-
// Public API
export function save(): JSONContent | null {
return editor?.getJSON() || null
@@ -789,6 +789,10 @@
class:with-toolbar={showToolbar}
style={`min-height: ${minHeight}px`}
>
+
+ {#if editor}
+
+ {/if}
@@ -1108,6 +1112,91 @@
}
}
+ /* Block spacing for visual separation in composer */
+ :global(.ProseMirror p) {
+ margin-top: $unit;
+ margin-bottom: $unit;
+ }
+
+ :global(.ProseMirror h1),
+ :global(.ProseMirror h2),
+ :global(.ProseMirror h3),
+ :global(.ProseMirror h4),
+ :global(.ProseMirror h5),
+ :global(.ProseMirror h6) {
+ padding-top: $unit;
+ padding-bottom: $unit;
+ }
+
+ :global(.ProseMirror ul),
+ :global(.ProseMirror ol) {
+ padding-top: $unit;
+ padding-bottom: $unit;
+ }
+
+ :global(.ProseMirror blockquote) {
+ margin-top: $unit;
+ margin-bottom: $unit;
+ }
+
+ :global(.ProseMirror pre),
+ :global(.ProseMirror .code-wrapper) {
+ margin-top: $unit;
+ margin-bottom: $unit;
+ }
+
+ :global(.ProseMirror hr) {
+ margin-top: $unit;
+ margin-bottom: $unit;
+ }
+
+ :global(.ProseMirror .tableWrapper) {
+ margin-top: $unit;
+ margin-bottom: $unit;
+ }
+
+ :global(.ProseMirror img),
+ :global(.ProseMirror video),
+ :global(.ProseMirror audio),
+ :global(.ProseMirror iframe) {
+ margin-top: $unit;
+ margin-bottom: $unit;
+ }
+
+ :global(.ProseMirror .image-placeholder),
+ :global(.ProseMirror .video-placeholder),
+ :global(.ProseMirror .audio-placeholder),
+ :global(.ProseMirror .gallery-placeholder),
+ :global(.ProseMirror .url-embed-placeholder),
+ :global(.ProseMirror .geolocation-placeholder) {
+ margin-top: $unit;
+ margin-bottom: $unit;
+ }
+
+ :global(.ProseMirror .node-urlEmbed) {
+ padding-top: $unit;
+ padding-bottom: $unit;
+ }
+
+ /* Link styling */
+ :global(.ProseMirror a) {
+ color: $accent-color;
+ text-decoration: none;
+ cursor: pointer;
+ }
+
+ :global(.ProseMirror a span) {
+ color: inherit !important;
+ }
+
+ :global(.ProseMirror a:hover) {
+ color: $red-40;
+ }
+
+ :global(.ProseMirror a:hover span) {
+ color: inherit !important;
+ }
+
/* Text Style Dropdown Styles */
.text-style-dropdown {
position: relative;
@@ -1226,4 +1315,33 @@
transform: rotate(360deg);
}
}
+
+ /* Drag handle styles */
+ :global(.drag-handle) {
+ position: fixed;
+ width: 20px;
+ height: 24px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: grab;
+ opacity: 0;
+ transition: opacity 0.2s;
+ color: $grey-40;
+ z-index: 50;
+ }
+
+ :global(.drag-handle.hide) {
+ opacity: 0 !important;
+ pointer-events: none;
+ }
+
+ :global(.drag-handle:not(.hide)) {
+ opacity: 1;
+ }
+
+ :global(.drag-handle:active) {
+ cursor: grabbing;
+ }
+
diff --git a/src/lib/components/admin/EssayForm.svelte b/src/lib/components/admin/EssayForm.svelte
index 32b07b1..7ac6dac 100644
--- a/src/lib/components/admin/EssayForm.svelte
+++ b/src/lib/components/admin/EssayForm.svelte
@@ -120,7 +120,7 @@
}
const savedPost = await response.json()
-
+
toast.dismiss(loadingToastId)
toast.success(`Essay ${mode === 'edit' ? 'saved' : 'created'} successfully!`)
diff --git a/src/lib/components/admin/MediaDetailsModal.svelte b/src/lib/components/admin/MediaDetailsModal.svelte
index 7f8ccd7..d35f372 100644
--- a/src/lib/components/admin/MediaDetailsModal.svelte
+++ b/src/lib/components/admin/MediaDetailsModal.svelte
@@ -135,7 +135,7 @@
const updatedMedia = await response.json()
onUpdate(updatedMedia)
-
+
toast.dismiss(loadingToastId)
toast.success('Media updated successfully!')
@@ -546,9 +546,7 @@
diff --git a/src/lib/components/admin/PhotoPostForm.svelte b/src/lib/components/admin/PhotoPostForm.svelte
index 58880fb..61614f6 100644
--- a/src/lib/components/admin/PhotoPostForm.svelte
+++ b/src/lib/components/admin/PhotoPostForm.svelte
@@ -90,7 +90,9 @@
return
}
- const loadingToastId = toast.loading(`${status === 'published' ? 'Publishing' : 'Saving'} photo post...`)
+ const loadingToastId = toast.loading(
+ `${status === 'published' ? 'Publishing' : 'Saving'} photo post...`
+ )
try {
isSaving = true
diff --git a/src/lib/components/admin/ProjectForm.svelte b/src/lib/components/admin/ProjectForm.svelte
index 46a9395..490dd24 100644
--- a/src/lib/components/admin/ProjectForm.svelte
+++ b/src/lib/components/admin/ProjectForm.svelte
@@ -172,7 +172,7 @@
}
const savedProject = await response.json()
-
+
toast.dismiss(loadingToastId)
toast.success(`Project ${mode === 'edit' ? 'saved' : 'created'} successfully!`)
diff --git a/src/lib/components/admin/SimplePostForm.svelte b/src/lib/components/admin/SimplePostForm.svelte
index 5707cef..43e4419 100644
--- a/src/lib/components/admin/SimplePostForm.svelte
+++ b/src/lib/components/admin/SimplePostForm.svelte
@@ -63,7 +63,9 @@
return
}
- const loadingToastId = toast.loading(`${publishStatus === 'published' ? 'Publishing' : 'Saving'} post...`)
+ const loadingToastId = toast.loading(
+ `${publishStatus === 'published' ? 'Publishing' : 'Saving'} post...`
+ )
try {
isSaving = true
diff --git a/src/lib/components/edra/drag-handle.svelte b/src/lib/components/edra/drag-handle.svelte
index 42e033a..8a40c57 100644
--- a/src/lib/components/edra/drag-handle.svelte
+++ b/src/lib/components/edra/drag-handle.svelte
@@ -1,31 +1,455 @@
-
-
-
+{#if dragHandleContainer}
+ {
+ console.log('Dropdown closed')
+ isMenuOpen = false
+ }}
+ />
+{/if}
diff --git a/src/lib/components/edra/editor.ts b/src/lib/components/edra/editor.ts
index 83b2343..7f2ddd8 100644
--- a/src/lib/components/edra/editor.ts
+++ b/src/lib/components/edra/editor.ts
@@ -117,7 +117,6 @@ export const initiateEditor = (
limit
}),
SearchAndReplace,
-
...(extensions ?? [])
],
autofocus: true,
diff --git a/src/lib/components/edra/extensions/drag-handle/index.ts b/src/lib/components/edra/extensions/drag-handle/index.ts
index 06f6e8e..1e3bf50 100644
--- a/src/lib/components/edra/extensions/drag-handle/index.ts
+++ b/src/lib/components/edra/extensions/drag-handle/index.ts
@@ -3,6 +3,7 @@ import { NodeSelection, Plugin, PluginKey, TextSelection } from '@tiptap/pm/stat
import { Fragment, Slice, Node } from '@tiptap/pm/model'
import { EditorView } from '@tiptap/pm/view'
import { serializeForClipboard } from './ClipboardSerializer.js'
+import DragHandleIcon from '$icons/drag-handle.svg?raw'
export interface GlobalDragHandleOptions {
/**
@@ -70,6 +71,9 @@ function nodeDOMAtCoords(coords: { x: number; y: number }, options: GlobalDragHa
'h4',
'h5',
'h6',
+ '[data-drag-handle]', // NodeView components with drag handle
+ '.edra-url-embed-wrapper', // URL embed wrapper
+ '.edra-youtube-embed-card', // YouTube embed
...options.customNodes.map((node) => `[data-type=${node}]`)
].join(', ')
return document
@@ -192,7 +196,11 @@ export function DragHandlePlugin(options: GlobalDragHandleOptions & { pluginKey:
const relatedTarget = event.relatedTarget as HTMLElement
const isInsideEditor =
relatedTarget?.classList.contains('tiptap') ||
- relatedTarget?.classList.contains('drag-handle')
+ relatedTarget?.classList.contains('drag-handle') ||
+ relatedTarget?.classList.contains('drag-handle-menu') ||
+ relatedTarget?.classList.contains('dropdown-menu') ||
+ relatedTarget?.closest('.drag-handle') ||
+ relatedTarget?.closest('.dropdown-menu')
if (isInsideEditor) return
}
@@ -209,6 +217,11 @@ export function DragHandlePlugin(options: GlobalDragHandleOptions & { pluginKey:
dragHandleElement.draggable = true
dragHandleElement.dataset.dragHandle = ''
dragHandleElement.classList.add('drag-handle')
+
+ // Add custom drag handle SVG if element was created (not selected)
+ if (!handleBySelector) {
+ dragHandleElement.innerHTML = DragHandleIcon
+ }
function onDragHandleDragStart(e: DragEvent) {
handleDragStart(e, view)
@@ -254,6 +267,18 @@ export function DragHandlePlugin(options: GlobalDragHandleOptions & { pluginKey:
return
}
+ // Check if we're hovering over the drag handle itself
+ const target = event.target as HTMLElement
+ if (target.closest('.drag-handle') || target.closest('.dropdown-menu')) {
+ // Keep the handle visible when hovering over it or the dropdown
+ return
+ }
+
+ // Don't move the drag handle if the menu is open
+ if (dragHandleElement?.classList.contains('menu-open')) {
+ return
+ }
+
const node = nodeDOMAtCoords(
{
x: event.clientX + 50 + options.dragHandleWidth,
@@ -274,21 +299,34 @@ export function DragHandlePlugin(options: GlobalDragHandleOptions & { pluginKey:
if (nodePos !== undefined) {
const currentNode = view.state.doc.nodeAt(nodePos)
if (currentNode !== null) {
+ // Still update the current node for tracking, but don't reposition if menu is open
options.onMouseMove?.({ node: currentNode, pos: nodePos })
}
}
- 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)
+ // Don't reposition the drag handle if menu is open
+ if (dragHandleElement?.classList.contains('menu-open')) {
+ return
+ }
+ const compStyle = window.getComputedStyle(node)
+ const paddingTop = parseInt(compStyle.paddingTop, 10)
const rect = absoluteRect(node)
- rect.top += (lineHeight - 24) / 2
- rect.top += paddingTop
+ // For custom nodes like embeds, position at the top of the element
+ const isCustomNode = node.matches('[data-drag-handle], .edra-url-embed-wrapper, .edra-youtube-embed-card, [data-type]')
+ if (isCustomNode) {
+ // For NodeView components, position handle at top with small offset
+ rect.top += 8
+ } else {
+ // For text nodes, calculate based on line height
+ const parsedLineHeight = parseInt(compStyle.lineHeight, 10)
+ const lineHeight = isNaN(parsedLineHeight)
+ ? parseInt(compStyle.fontSize) * 1.2
+ : parsedLineHeight
+ 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
@@ -297,8 +335,9 @@ export function DragHandlePlugin(options: GlobalDragHandleOptions & { pluginKey:
if (!dragHandleElement) return
- dragHandleElement.style.left = `${rect.left - rect.width}px`
- dragHandleElement.style.top = `${rect.top}px`
+ // Add 8px gap between drag handle and content
+ dragHandleElement.style.left = `${rect.left - rect.width - 8}px`
+ dragHandleElement.style.top = `${rect.top - 4}px` // Offset for padding
showDragHandle()
},
keydown: () => {
diff --git a/src/lib/components/edra/extensions/image/ImageExtended.ts b/src/lib/components/edra/extensions/image/ImageExtended.ts
index e476597..489f5fa 100644
--- a/src/lib/components/edra/extensions/image/ImageExtended.ts
+++ b/src/lib/components/edra/extensions/image/ImageExtended.ts
@@ -27,8 +27,8 @@ export const ImageExtended = (component: Component): Node element.getAttribute('data-media-id'),
- renderHTML: attributes => {
+ parseHTML: (element) => element.getAttribute('data-media-id'),
+ renderHTML: (attributes) => {
if (!attributes.mediaId) {
return {}
}
diff --git a/src/lib/components/edra/headless/components/EnhancedImagePlaceholder.svelte b/src/lib/components/edra/headless/components/EnhancedImagePlaceholder.svelte
index 5ea2be2..bbba72e 100644
--- a/src/lib/components/edra/headless/components/EnhancedImagePlaceholder.svelte
+++ b/src/lib/components/edra/headless/components/EnhancedImagePlaceholder.svelte
@@ -137,7 +137,6 @@
// Set a reasonable default width (max 600px)
const displayWidth = media.width && media.width > 600 ? 600 : media.width
-
const imageAttrs = {
src: media.url,
alt: media.altText || '',
@@ -147,7 +146,7 @@
align: 'center',
mediaId: media.id?.toString()
}
-
+
editor
.chain()
.focus()
diff --git a/src/lib/stores/toast.ts b/src/lib/stores/toast.ts
index 3a21d14..744374a 100644
--- a/src/lib/stores/toast.ts
+++ b/src/lib/stores/toast.ts
@@ -2,7 +2,13 @@ import { toast as sonnerToast } from 'svelte-sonner'
export interface ToastOptions {
duration?: number
- position?: 'top-left' | 'top-center' | 'top-right' | 'bottom-left' | 'bottom-center' | 'bottom-right'
+ position?:
+ | 'top-left'
+ | 'top-center'
+ | 'top-right'
+ | 'bottom-left'
+ | 'bottom-center'
+ | 'bottom-right'
description?: string
action?: {
label: string
@@ -82,4 +88,4 @@ export const toast = {
...options
})
}
-}
\ No newline at end of file
+}
diff --git a/src/lib/utils/content.ts b/src/lib/utils/content.ts
index 4be07d5..beebd87 100644
--- a/src/lib/utils/content.ts
+++ b/src/lib/utils/content.ts
@@ -63,7 +63,7 @@ export const renderEdraContent = (content: any): string => {
const src = block.attrs?.src || block.src || ''
const alt = block.attrs?.alt || block.alt || ''
const caption = block.attrs?.caption || block.caption || ''
-
+
// Check if we have a media ID stored in attributes first
const mediaId = block.attrs?.mediaId || block.mediaId || extractMediaIdFromUrl(src)
@@ -158,7 +158,7 @@ function renderTiptapContent(doc: any): string {
const height = node.attrs?.height
const widthAttr = width ? ` width="${width}"` : ''
const heightAttr = height ? ` height="${height}"` : ''
-
+
// Check if we have a media ID stored in attributes first
const mediaId = node.attrs?.mediaId || extractMediaIdFromUrl(src)
diff --git a/src/routes/albums/[slug]/+page.svelte b/src/routes/albums/[slug]/+page.svelte
index 715fb84..651604b 100644
--- a/src/routes/albums/[slug]/+page.svelte
+++ b/src/routes/albums/[slug]/+page.svelte
@@ -170,7 +170,9 @@
📍 {album.location}
{/if}
📷 {album.photos?.length || 0} photo{(album.photos?.length || 0) !== 1 ? 's' : ''}📷 {album.photos?.length || 0} photo{(album.photos?.length || 0) !== 1
+ ? 's'
+ : ''}