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' + : ''}