From 15171e3deef013703668328ccda0cf6ef6291651 Mon Sep 17 00:00:00 2001 From: lzinga Date: Fri, 28 Nov 2025 13:44:57 -0800 Subject: [PATCH 1/4] feat(gallery): add gallery note type and implement gallery widget with image management features --- apps/client/src/entities/fnote.ts | 5 +- apps/client/src/services/note_types.ts | 3 + .../src/translations/en/translation.json | 36 +- apps/client/src/widgets/note_types.tsx | 5 + .../src/widgets/type_widgets/Gallery.css | 367 ++++++++++ .../src/widgets/type_widgets/Gallery.tsx | 624 ++++++++++++++++++ apps/server/internal.openapi.yaml | 1 + packages/commons/src/lib/rows.ts | 3 +- 8 files changed, 1040 insertions(+), 4 deletions(-) create mode 100644 apps/client/src/widgets/type_widgets/Gallery.css create mode 100644 apps/client/src/widgets/type_widgets/Gallery.tsx diff --git a/apps/client/src/entities/fnote.ts b/apps/client/src/entities/fnote.ts index 5fe9bc67d..636fd02a8 100644 --- a/apps/client/src/entities/fnote.ts +++ b/apps/client/src/entities/fnote.ts @@ -27,7 +27,8 @@ const NOTE_TYPE_ICONS = { doc: "bx bxs-file-doc", contentWidget: "bx bxs-widget", mindMap: "bx bx-sitemap", - aiChat: "bx bx-bot" + aiChat: "bx bx-bot", + gallery: "bx bx-image-alt" }; /** @@ -35,7 +36,7 @@ const NOTE_TYPE_ICONS = { * end user. Those types should be used only for checking against, they are * not for direct use. */ -export type NoteType = "file" | "image" | "search" | "noteMap" | "launcher" | "doc" | "contentWidget" | "text" | "relationMap" | "render" | "canvas" | "mermaid" | "book" | "webView" | "code" | "mindMap" | "aiChat"; +export type NoteType = "file" | "image" | "search" | "noteMap" | "launcher" | "doc" | "contentWidget" | "text" | "relationMap" | "render" | "canvas" | "mermaid" | "book" | "webView" | "code" | "mindMap" | "aiChat" | "gallery"; export interface NotePathRecord { isArchived: boolean; diff --git a/apps/client/src/services/note_types.ts b/apps/client/src/services/note_types.ts index 74b6f5665..4f2031d3c 100644 --- a/apps/client/src/services/note_types.ts +++ b/apps/client/src/services/note_types.ts @@ -34,6 +34,9 @@ export const NOTE_TYPES: NoteTypeMapping[] = [ { type: "canvas", mime: "application/json", title: t("note_types.canvas"), icon: "bx-pen" }, { type: "mermaid", mime: "text/mermaid", title: t("note_types.mermaid-diagram"), icon: "bx-selection" }, + // Media notes + { type: "gallery", mime: "", title: t("note_types.gallery"), icon: "bx-image-alt", reserved: false }, + // Map notes { type: "mindMap", mime: "application/json", title: t("note_types.mind-map"), icon: "bx-sitemap" }, { type: "noteMap", mime: "", title: t("note_types.note-map"), icon: "bxs-network-chart", static: true }, diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index 53427faae..faaa48530 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -1673,7 +1673,8 @@ "ai-chat": "AI Chat", "task-list": "Task List", "new-feature": "New", - "collections": "Collections" + "collections": "Collections", + "gallery": "Gallery" }, "protect_note": { "toggle-on": "Protect the note", @@ -2107,5 +2108,38 @@ "clear-color": "Clear note color", "set-color": "Set note color", "set-custom-color": "Set custom note color" + }, + "gallery": { + "no_images": "No Images", + "upload_first_image": "Upload First Image", + "image_count": "{{count}} Image", + "upload_images": "Upload Images", + "image_count_plural": "{{count}} Images", + "confirm_delete_note": "Are you sure you want to delete {{title}}", + "confirm_delete_attachment": "Are you sure you want to delete the attachment {{title}}", + "delete_note_success": "{{title}} deleted successfully", + "delete_attachment_success": "{{title}} deleted successfully", + "delete_error": "An error occurred while attempting to delete {{title}} - {{message}}", + "confirm_delete_multiple": "Are you sure you want to delete {{count}} items?", + "delete_multiple_success": "{{count}} items deleted successfully!", + "delete_multiple_error": "An error has occurred while attempting to delete {{count}} images.", + "cancel_selection": "Cancel Multi Selection", + "select": "Select Multiple", + "select_all": "Select All", + "deselect_all": "Deselect All", + "delete_selected": "Delete {{count}} Items", + "delete": "Delete", + "previous": "Previous", + "next": "Next", + "close": "close", + "goto_note": "Go To Parent Note", + "warning_delete_other_note": "This image belongs to another note, deleting it will remove it from that note!", + "cancel": "cancel", + "copy_image_link": "Image Link Copied to Clipboard", + "share_copy_failed": "Failed to copy image link to clipboard.", + "select_image": "Select Image", + "show_child_images_btn": "Show Child Images", + "hide_child_images_btn": "Hide Child Images", + "toggle_child_images_tooltip": "Toggle Child Images" } } diff --git a/apps/client/src/widgets/note_types.tsx b/apps/client/src/widgets/note_types.tsx index a6b3feb27..c501ba3fc 100644 --- a/apps/client/src/widgets/note_types.tsx +++ b/apps/client/src/widgets/note_types.tsx @@ -139,5 +139,10 @@ export const TYPE_MAPPINGS: Record = { view: () => import("./type_widgets/AiChat"), className: "ai-chat-widget-container", isFullHeight: true + }, + gallery: { + view: () => import("./type_widgets/Gallery"), + className: "note-detail-gallery", + isFullHeight: true } }; diff --git a/apps/client/src/widgets/type_widgets/Gallery.css b/apps/client/src/widgets/type_widgets/Gallery.css new file mode 100644 index 000000000..c09b0ff49 --- /dev/null +++ b/apps/client/src/widgets/type_widgets/Gallery.css @@ -0,0 +1,367 @@ +.gallery-container { + padding: 1rem; + height: 100%; + overflow-y: auto; + display: flex; + flex-direction: column; +} + +.gallery-toolbar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5rem; + margin-bottom: 1rem; + border-bottom: 1px solid var(--main-border-color); +} + +.gallery-count { + color: var(--muted-text-color); + font-size: 0.875rem; +} + +/* Masonry Grid Layout */ +.gallery-masonry { + column-count: 4; + column-gap: 1rem; + padding: 0.5rem; +} + +@media (max-width: 1400px) { + .gallery-masonry { + column-count: 3; + } +} + +@media (max-width: 1000px) { + .gallery-masonry { + column-count: 2; + } +} + +@media (max-width: 600px) { + .gallery-masonry { + column-count: 1; + } +} + +.gallery-item { + position: relative; + break-inside: avoid; + margin-bottom: 1rem; + cursor: pointer; + border-radius: 8px; + overflow: hidden; + background: var(--main-background-color); + border: 1px solid var(--main-border-color); + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.gallery-item:hover { + transform: translateY(-4px); + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15); +} + +.gallery-item img { + width: 100%; + height: auto; + display: block; +} + +.gallery-item-overlay { + position: absolute; + bottom: 0; + left: 0; + right: 0; + background: linear-gradient(to top, rgba(0, 0, 0, 0.8) 0%, rgba(0, 0, 0, 0) 100%); + padding: 2rem 1rem 0.75rem; + opacity: 0; + transition: opacity 0.2s ease; +} + +.gallery-item:hover .gallery-item-overlay { + opacity: 1; +} + +.gallery-item-title { + color: white; + font-size: 0.875rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.gallery-item-type { + position: absolute; + top: 0.5rem; + right: 0.5rem; + background: rgba(0, 0, 0, 0.6); + color: white; + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 1rem; +} + +/* Empty State */ +.gallery-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 4rem 2rem; + text-align: center; + color: var(--muted-text-color); + flex: 1; +} + +.gallery-empty-icon { + font-size: 5rem; + opacity: 0.3; + margin-bottom: 1rem; +} + +.gallery-empty p { + margin: 0.5rem 0; +} + +.gallery-empty-hint { + font-size: 0.875rem; + margin-bottom: 1.5rem !important; +} + +/* Lightbox */ +.gallery-lightbox { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.95); + z-index: 10000; + display: flex; + align-items: center; + justify-content: center; + animation: fadeIn 0.2s ease; +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +.gallery-lightbox-content { + max-width: 90vw; + max-height: 90vh; + display: flex; + flex-direction: column; + align-items: center; +} + +.gallery-lightbox-content img { + max-width: 100%; + max-height: calc(90vh - 60px); + object-fit: contain; + border-radius: 4px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); +} + +.gallery-lightbox-info { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + margin-top: 1rem; + padding: 0 1rem; + color: white; +} + +.gallery-lightbox-info h3 { + margin: 0; + font-size: 1.25rem; + font-weight: 500; +} + +.gallery-lightbox-counter { + font-size: 0.875rem; + opacity: 0.7; +} + +.gallery-lightbox-close { + position: absolute; + top: 1rem; + right: 1rem; + background: rgba(255, 255, 255, 0.1); + border: none; + color: white; + font-size: 2rem; + width: 3rem; + height: 3rem; + border-radius: 50%; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.2s ease; + z-index: 10001; +} + +.gallery-lightbox-close:hover { + background: rgba(255, 255, 255, 0.2); +} + +.gallery-lightbox-nav { + position: absolute; + top: 50%; + transform: translateY(-50%); + background: rgba(255, 255, 255, 0.1); + border: none; + color: white; + font-size: 2rem; + width: 3rem; + height: 3rem; + border-radius: 50%; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.2s ease; + z-index: 10001; +} + +.gallery-lightbox-nav:hover { + background: rgba(255, 255, 255, 0.2); +} + +.gallery-lightbox-prev { + left: 1rem; +} + +.gallery-lightbox-next { + right: 1rem; +} + +/* Selection Mode */ +.gallery-item.selection-mode { + cursor: pointer; +} + +.gallery-item.selected { + border: 3px solid var(--button-border-color); + box-shadow: 0 0 0 2px rgba(66, 133, 244, 0.3); +} + +.gallery-item-checkbox { + position: absolute; + top: 0.5rem; + left: 0.5rem; + z-index: 10; + background: rgba(0, 0, 0, 0.7); + backdrop-filter: blur(4px); + border-radius: 4px; + padding: 0.5rem; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); +} + +.gallery-item-checkbox input[type="checkbox"] { + width: 18px; + height: 18px; + cursor: pointer; + margin: 0; + accent-color: var(--button-border-color); +} + +.gallery-item-actions { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.gallery-item-delete { + background: rgba(220, 53, 69, 0.9); + color: white; + border: none; + border-radius: 4px; + padding: 0.375rem 0.5rem; + cursor: pointer; + font-size: 1.125rem; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.2s ease; +} + +.gallery-item-delete:hover { + background: rgba(220, 53, 69, 1); +} + +.gallery-lightbox-delete { + position: absolute; + top: 1rem; + right: 5rem; + background: rgba(220, 53, 69, 0.9); + border: none; + color: white; + font-size: 1.5rem; + width: 3rem; + height: 3rem; + border-radius: 50%; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.2s ease; + z-index: 10001; +} + +.gallery-lightbox-delete:hover { + background: rgba(220, 53, 69, 1); +} + +/* Toolbar Styles */ +.gallery-toolbar-left { + display: flex; + gap: 0.5rem; +} + +.gallery-selection-actions { + display: flex; + gap: 0.5rem; +} + +.btn-danger { + background: rgba(220, 53, 69, 0.1); + color: var(--danger-color, #dc3545); +} + +.btn-danger:hover:not(:disabled) { + background: rgba(220, 53, 69, 0.2); +} +.gallery-item-link { + position: absolute; + bottom: 0.75rem; + right: 0.75rem; + background: rgba(108, 117, 125, 0.9); + color: white; + border: none; + border-radius: 4px; + padding: 0.375rem 0.5rem; + cursor: pointer; + font-size: 1.125rem; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.2s ease, opacity 0.2s ease; + opacity: 0; + z-index: 100; +} + +.gallery-item:hover .gallery-item-link { + opacity: 1; +} + +.gallery-item-link:hover { + background: rgba(108, 117, 125, 1); +} \ No newline at end of file diff --git a/apps/client/src/widgets/type_widgets/Gallery.tsx b/apps/client/src/widgets/type_widgets/Gallery.tsx new file mode 100644 index 000000000..143780637 --- /dev/null +++ b/apps/client/src/widgets/type_widgets/Gallery.tsx @@ -0,0 +1,624 @@ +import { TypeWidgetProps } from "./type_widget"; +import { useContext, useEffect, useRef, useState } from "preact/hooks"; +import { useTriliumEvent, useTriliumOption } from "../react/hooks"; +import FNote from "../../entities/fnote"; +import FAttachment from "../../entities/fattachment"; +import { ParentComponent } from "../react/react_utils"; +import Button from "../react/Button"; +import { t } from "../../services/i18n"; +import dialog from "../../services/dialog"; +import server from "../../services/server"; +import toast from "../../services/toast"; +import appContext from "../../components/app_context"; +import { formatSize } from "../../services/utils"; +import branches from "../../services/branches"; +import sync from "../../services/sync"; +import "./Gallery.css"; + +interface ImageItem { + id: string; + url: string; + title: string; + type: 'note' | 'attachment'; + noteId?: string; // For attachments, store the parent note ID + size?: number; // Size in bytes +} + +export default function Gallery({ note }: TypeWidgetProps) { + const [images, setImages] = useState([]); + const [selectedImage, setSelectedImage] = useState(null); + const [selectedIndex, setSelectedIndex] = useState(-1); + const [selectedImages, setSelectedImages] = useState>(new Set()); + const [isSelectionMode, setIsSelectionMode] = useState(false); + const [totalSize, setTotalSize] = useState(0); + const [isGalleryShared, setIsGalleryShared] = useState(false); + const [syncServerHost] = useTriliumOption("syncServerHost"); + const parentComponent = useContext(ParentComponent); + + async function loadImages() { + const imageItems: ImageItem[] = []; + let calculatedTotalSize = 0; + + // Check if the gallery note itself is shared + const galleryIsShared = note.hasAncestor("_share"); + setIsGalleryShared(galleryIsShared); + + // Check if we should hide child images + const hideChildImages = note.hasLabel("hideChildAttachments"); + + // First, check for images attached directly to this gallery note + const directAttachments = await note.getAttachments(); + const directImageAttachments = directAttachments.filter(a => a.role === "image"); + + for (const attachment of directImageAttachments) { + const size = attachment.contentLength || 0; + calculatedTotalSize += size; + + imageItems.push({ + id: attachment.attachmentId, + url: `api/attachments/${attachment.attachmentId}/image/${encodeURIComponent(attachment.title)}`, + title: attachment.title, + type: 'attachment' as const, + noteId: attachment.ownerId, + size + }); + } + + // Only load child notes and their attachments if hideChildImages is not set + if (!hideChildImages) { + // Then check child notes + const childNotes = await note.getChildNotes(); + const imageNotes = childNotes.filter(n => n.type === "image"); + + // Convert image notes to ImageItem format + for (const imageNote of imageNotes) { + const blob = await imageNote.getBlob(); + const size = blob?.contentLength || 0; + calculatedTotalSize += size; + + imageItems.push({ + id: imageNote.noteId, + url: `api/images/${imageNote.noteId}/${encodeURIComponent(imageNote.title)}`, + title: imageNote.title, + type: 'note' as const, + size + }); + } + + // Also check for notes with image attachments + for (const childNote of childNotes) { + const attachments = await childNote.getAttachments(); + const imageAttachments = attachments.filter(a => a.role === "image"); + + for (const attachment of imageAttachments) { + const size = attachment.contentLength || 0; + calculatedTotalSize += size; + + imageItems.push({ + id: attachment.attachmentId, + url: `api/attachments/${attachment.attachmentId}/image/${encodeURIComponent(attachment.title)}`, + title: attachment.title, + type: 'attachment' as const, + noteId: attachment.ownerId, + size + }); + } + } + } + + setImages(imageItems); + setTotalSize(calculatedTotalSize); + } + + useEffect(() => { + loadImages(); + }, [note]); + + useTriliumEvent("entitiesReloaded", ({ loadResults }) => { + const childNoteIds = images.map(img => img.noteId).filter(Boolean); + + // Reload if branches change (child notes added/removed or share status changed) + const branchRows = loadResults.getBranchRows(); + if (branchRows.some(b => b.parentNoteId === note.noteId || b.noteId === note.noteId)) { + loadImages(); + return; + } + + // Reload if any child note changes + const noteIds = loadResults.getNoteIds(); + if (noteIds.some(id => id === note.noteId || childNoteIds.includes(id))) { + loadImages(); + return; + } + + // Reload if attachments change for this note or its children + const attachmentRows = loadResults.getAttachmentRows(); + if (attachmentRows.some(att => att.ownerId === note.noteId || childNoteIds.includes(att.ownerId))) { + loadImages(); + return; + } + + // Reload if attributes change (for hideChildImages label) + const attributeRows = loadResults.getAttributeRows(); + if (attributeRows.some(attr => attr.noteId === note.noteId && attr.name === "hideChildAttachments")) { + loadImages(); + return; + } + }); + + function handleUploadImages() { + parentComponent?.triggerCommand("showUploadAttachmentsDialog", { noteId: note.noteId }); + } + + function handleImageClick(img: ImageItem, index: number, e: MouseEvent) { + e.stopPropagation(); + + if (isSelectionMode) { + toggleImageSelection(img.id); + } else { + setSelectedImage(img); + setSelectedIndex(index); + } + } + + function handleLinkClick(img: ImageItem, e: MouseEvent) { + e.stopPropagation(); + + if (img.type === 'note') { + // Navigate to the note + appContext.tabManager.getActiveContext()?.setNote(img.id); + } else if (img.noteId && img.noteId !== note.noteId) { + // For attachments on child notes, navigate to the parent note + appContext.tabManager.getActiveContext()?.setNote(img.noteId); + } + // Note: No else clause - we don't do anything for gallery note attachments + } + + async function handleToggleShare() { + if (isGalleryShared) { + // Unshare the gallery + if (note.getParentBranches().length === 1 && !(await dialog.confirm(t("gallery.confirm_unshare_only_location")))) { + return; + } + + const shareBranch = note.getParentBranches().find((b) => b.parentNoteId === "_share"); + if (shareBranch?.branchId) { + await server.remove(`branches/${shareBranch.branchId}`); + toast.showMessage(t("gallery.unshared_success")); + sync.syncNow(true); + await loadImages(); + } + } else { + // Share the gallery + await branches.cloneNoteToParentNote(note.noteId, "_share"); + toast.showMessage(t("gallery.shared_success")); + sync.syncNow(true); + await loadImages(); + } + } + + async function handleCopyImageLink(img: ImageItem, e: MouseEvent) { + e.stopPropagation(); + + if (!isGalleryShared) { + toast.showError(t("gallery.not_shared")); + return; + } + + let imageUrl: string; + + if (img.type === 'note') { + // For image notes, get their share URL + const imageNote = note.froca.getNoteFromCache(img.id); + if (!imageNote) return; + + const shareId = imageNote.getOwnedLabelValue("shareAlias") || img.id; + imageUrl = getAbsoluteUrl(`/share/api/images/${shareId}/${encodeURIComponent(img.title)}`); + } else { + // For attachments, use the attachment API + imageUrl = getAbsoluteUrl(`/share/api/attachments/${img.id}/image/${encodeURIComponent(img.title)}`); + } + + try { + await navigator.clipboard.writeText(imageUrl); + toast.showMessage(t("gallery.copy_image_link")); + } catch (error) { + toast.showError(t("gallery.share_copy_failed")); + } + } + + async function handleToggleHideChildImages() { + const currentValue = note.hasLabel("hideChildAttachments"); + + if (currentValue) { + // Remove the attribute + const attr = note.getOwnedAttributes("label", "hideChildAttachments")[0]; + if (attr) { + await server.remove(`notes/${note.noteId}/attributes/${attr.attributeId}`); + toast.showMessage(t("gallery.show_child_images_btn")); + } + } else { + // Add the attribute + await server.post(`notes/${note.noteId}/attributes`, { + type: "label", + name: "hideChildAttachments", + value: "" + }); + toast.showMessage(t("gallery.hide_child_images_btn")); + } + + await loadImages(); + } + + function getShareUrl(noteId: string, syncServerHost: string | null): string | null { + const shareId = note.getOwnedLabelValue("shareAlias") || noteId; + + if (syncServerHost) { + return new URL(`/share/${shareId}`, syncServerHost).href; + } else { + let host = location.host; + if (host.endsWith("/")) { + host = host.substring(0, host.length - 1); + } + return `${location.protocol}//${host}${location.pathname}share/${shareId}`; + } + } + + function getAbsoluteUrl(path: string): string { + if (syncServerHost) { + return new URL(path, syncServerHost).href; + } else { + let host = location.host; + if (host.endsWith("/")) { + host = host.substring(0, host.length - 1); + } + return `${location.protocol}//${host}${location.pathname.replace(/\/$/, '')}${path}`; + } + } + + function toggleImageSelection(imageId: string) { + setSelectedImages(prev => { + const newSet = new Set(prev); + if (newSet.has(imageId)) { + newSet.delete(imageId); + } else { + newSet.add(imageId); + } + return newSet; + }); + } + + function toggleSelectionMode() { + setIsSelectionMode(!isSelectionMode); + if (isSelectionMode) { + setSelectedImages(new Set()); + } + } + + function selectAll() { + setSelectedImages(new Set(images.map(img => img.id))); + } + + function deselectAll() { + setSelectedImages(new Set()); + } + + function closeLightbox() { + setSelectedImage(null); + setSelectedIndex(-1); + } + + function navigateImage(direction: 'prev' | 'next') { + if (images.length === 0) return; + + let newIndex = selectedIndex; + if (direction === 'prev') { + newIndex = selectedIndex > 0 ? selectedIndex - 1 : images.length - 1; + } else { + newIndex = selectedIndex < images.length - 1 ? selectedIndex + 1 : 0; + } + + setSelectedIndex(newIndex); + setSelectedImage(images[newIndex]); + } + + async function handleDeleteImage(img: ImageItem, e?: MouseEvent) { + if (e) { + e.stopPropagation(); + } + + // Check if this image belongs to another note + const belongsToOtherNote = img.type === 'note' || (img.noteId && img.noteId !== note.noteId); + + let confirmMessage = img.type === 'note' + ? t("gallery.confirm_delete_note", { title: img.title }) + : t("gallery.confirm_delete_attachment", { title: img.title }); + + // Add warning if it belongs to another note + if (belongsToOtherNote) { + confirmMessage += "\n\n" + t("gallery.warning_delete_other_note"); + } + + const confirmed = await dialog.confirm(confirmMessage); + + if (!confirmed) { + return; + } + + try { + if (img.type === 'note') { + await server.remove(`notes/${img.id}`); + toast.showMessage(t("gallery.delete_note_success", { title: img.title })); + } else { + await server.remove(`attachments/${img.id}`); + toast.showMessage(t("gallery.delete_attachment_success", { title: img.title })); + } + + if (selectedImage?.id === img.id) { + closeLightbox(); + } + + await loadImages(); + } catch (error: any) { + toast.showError(t("gallery.delete_error", { message: error.message || String(error), title: img.title })); + } + } + + async function handleDeleteSelected() { + if (selectedImages.size === 0) return; + + const confirmMessage = t("gallery.confirm_delete_multiple", { count: selectedImages.size }); + + if (!await dialog.confirm(confirmMessage)) { + return; + } + + const imagesToDelete = images.filter(img => selectedImages.has(img.id)); + let successCount = 0; + let errorCount = 0; + + for (const img of imagesToDelete) { + try { + if (img.type === 'note') { + await server.remove(`notes/${img.id}`); + } else { + await server.remove(`attachments/${img.id}`); + } + successCount++; + } catch (error) { + console.error(`Failed to delete image ${img.id}:`, error); + errorCount++; + } + } + + if (successCount > 0) { + toast.showMessage(t("gallery.delete_multiple_success", { count: successCount })); + } + if (errorCount > 0) { + toast.showError(t("gallery.delete_multiple_error", { count: errorCount })); + } + + setSelectedImages(new Set()); + setIsSelectionMode(false); + await loadImages(); + } + + function handleKeyDown(e: KeyboardEvent) { + if (!selectedImage) return; + + if (e.key === 'Escape') { + closeLightbox(); + } else if (e.key === 'ArrowLeft') { + navigateImage('prev'); + } else if (e.key === 'ArrowRight') { + navigateImage('next'); + } else if (e.key === 'Delete' || e.key === 'Backspace') { + handleDeleteImage(selectedImage); + } + } + + useEffect(() => { + if (selectedImage) { + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + } + }, [selectedImage, selectedIndex]); + + return ( +
+
+
+
+ + {isSelectionMode && ( +
+
+ )} + + + {images.length === 1 + ? t("gallery.image_count", { count: images.length }) + : t("gallery.image_count_plural", { count: images.length }) + } + {totalSize > 0 && ` (${formatSize(totalSize)})`} + +
+ + {images.length === 0 ? ( +
+
+ +
+

{t("gallery.no_images")}

+

{t("gallery.upload_hint")}

+
+ ) : ( +
+ {images.map((img, index) => ( +
handleImageClick(img, index, e)} + > + {isSelectionMode && ( +
+ toggleImageSelection(img.id)} + onClick={(e) => e.stopPropagation()} + aria-label={t("gallery.select_image", { title: img.title })} + /> +
+ )} + {img.title} +
+
{img.title}
+ {!isSelectionMode && ( +
+ {isGalleryShared && ( + + )} + +
+ )} +
+ {!isSelectionMode && (img.type === 'note' || (img.noteId && img.noteId !== note.noteId)) && ( + + )} +
+ ))} +
+ )} + + {selectedImage && !isSelectionMode && ( +
+ + + + + {isGalleryShared && ( + + )} + + {images.length > 1 && ( + <> + + + + )} + +
e.stopPropagation()}> + {selectedImage.title} +
+

{selectedImage.title}

+ + {selectedIndex + 1} / {images.length} + +
+
+
+ )} +
+ ); +} diff --git a/apps/server/internal.openapi.yaml b/apps/server/internal.openapi.yaml index 73cff4b62..5d1cc8183 100644 --- a/apps/server/internal.openapi.yaml +++ b/apps/server/internal.openapi.yaml @@ -4414,6 +4414,7 @@ components: - contentWidget - mindMap - geoMap + - gallery Branch: type: object diff --git a/packages/commons/src/lib/rows.ts b/packages/commons/src/lib/rows.ts index 5710cf84f..9dd607789 100644 --- a/packages/commons/src/lib/rows.ts +++ b/packages/commons/src/lib/rows.ts @@ -120,7 +120,8 @@ export const ALLOWED_NOTE_TYPES = [ "webView", "code", "mindMap", - "aiChat" + "aiChat", + "gallery" ] as const; export type NoteType = (typeof ALLOWED_NOTE_TYPES)[number]; From 6512d03c117a66ad5fa9ba5d7ad3101bfcf096e2 Mon Sep 17 00:00:00 2001 From: lzinga Date: Fri, 28 Nov 2025 14:23:29 -0800 Subject: [PATCH 2/4] feat(gallery): add gallery note type and update related translations and widget functionality --- apps/client/src/services/in_app_help.ts | 3 ++- apps/client/src/translations/en/translation.json | 5 ++++- apps/client/src/widgets/type_widgets/Gallery.tsx | 11 +++-------- apps/server/src/services/note_types.ts | 3 ++- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/apps/client/src/services/in_app_help.ts b/apps/client/src/services/in_app_help.ts index 2f805783a..06e8352b1 100644 --- a/apps/client/src/services/in_app_help.ts +++ b/apps/client/src/services/in_app_help.ts @@ -18,7 +18,8 @@ export const byNoteType: Record, string | null> = { search: null, text: null, webView: null, - aiChat: null + aiChat: null, + gallery: null }; export const byBookType: Record = { diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index faaa48530..7ea66d3f7 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -2140,6 +2140,9 @@ "select_image": "Select Image", "show_child_images_btn": "Show Child Images", "hide_child_images_btn": "Hide Child Images", - "toggle_child_images_tooltip": "Toggle Child Images" + "toggle_child_images_tooltip": "Toggle Child Images", + "not_shared": "The gallery is not shared", + "shared_success": "The gallery is now being shared", + "unshared_success": "The gallery has stopped being shared" } } diff --git a/apps/client/src/widgets/type_widgets/Gallery.tsx b/apps/client/src/widgets/type_widgets/Gallery.tsx index 143780637..9faf42347 100644 --- a/apps/client/src/widgets/type_widgets/Gallery.tsx +++ b/apps/client/src/widgets/type_widgets/Gallery.tsx @@ -9,6 +9,7 @@ import { t } from "../../services/i18n"; import dialog from "../../services/dialog"; import server from "../../services/server"; import toast from "../../services/toast"; +import froca from "../../services/froca"; import appContext from "../../components/app_context"; import { formatSize } from "../../services/utils"; import branches from "../../services/branches"; @@ -176,11 +177,6 @@ export default function Gallery({ note }: TypeWidgetProps) { async function handleToggleShare() { if (isGalleryShared) { - // Unshare the gallery - if (note.getParentBranches().length === 1 && !(await dialog.confirm(t("gallery.confirm_unshare_only_location")))) { - return; - } - const shareBranch = note.getParentBranches().find((b) => b.parentNoteId === "_share"); if (shareBranch?.branchId) { await server.remove(`branches/${shareBranch.branchId}`); @@ -197,6 +193,7 @@ export default function Gallery({ note }: TypeWidgetProps) { } } + // Your current handleCopyImageLink is actually fine for both Electron and web async function handleCopyImageLink(img: ImageItem, e: MouseEvent) { e.stopPropagation(); @@ -208,14 +205,12 @@ export default function Gallery({ note }: TypeWidgetProps) { let imageUrl: string; if (img.type === 'note') { - // For image notes, get their share URL - const imageNote = note.froca.getNoteFromCache(img.id); + const imageNote = froca.getNoteFromCache(img.id); if (!imageNote) return; const shareId = imageNote.getOwnedLabelValue("shareAlias") || img.id; imageUrl = getAbsoluteUrl(`/share/api/images/${shareId}/${encodeURIComponent(img.title)}`); } else { - // For attachments, use the attachment API imageUrl = getAbsoluteUrl(`/share/api/attachments/${img.id}/image/${encodeURIComponent(img.title)}`); } diff --git a/apps/server/src/services/note_types.ts b/apps/server/src/services/note_types.ts index 2aa86d0b6..2a998a1e8 100644 --- a/apps/server/src/services/note_types.ts +++ b/apps/server/src/services/note_types.ts @@ -15,7 +15,8 @@ const noteTypes = [ { type: "doc", defaultMime: "" }, { type: "contentWidget", defaultMime: "" }, { type: "mindMap", defaultMime: "application/json" }, - { type: "aiChat", defaultMime: "application/json" } + { type: "aiChat", defaultMime: "application/json" }, + { type: "gallery", defaultMime: "" } ]; function getDefaultMimeForNoteType(typeName: string) { From 4779492bb7f6a196b5cab91d1063d6aaeab56abe Mon Sep 17 00:00:00 2001 From: lzinga Date: Fri, 28 Nov 2025 15:01:25 -0800 Subject: [PATCH 3/4] capitalize "close" and "cancel" in translation strings feat(gallery): improve image loading performance --- .../src/translations/en/translation.json | 4 +- .../src/widgets/type_widgets/Gallery.css | 45 +++++- .../src/widgets/type_widgets/Gallery.tsx | 133 +++++++----------- 3 files changed, 99 insertions(+), 83 deletions(-) diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index 7ea66d3f7..e478e8a92 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -2131,10 +2131,10 @@ "delete": "Delete", "previous": "Previous", "next": "Next", - "close": "close", + "close": "Close", "goto_note": "Go To Parent Note", "warning_delete_other_note": "This image belongs to another note, deleting it will remove it from that note!", - "cancel": "cancel", + "cancel": "Cancel", "copy_image_link": "Image Link Copied to Clipboard", "share_copy_failed": "Failed to copy image link to clipboard.", "select_image": "Select Image", diff --git a/apps/client/src/widgets/type_widgets/Gallery.css b/apps/client/src/widgets/type_widgets/Gallery.css index c09b0ff49..557a184cd 100644 --- a/apps/client/src/widgets/type_widgets/Gallery.css +++ b/apps/client/src/widgets/type_widgets/Gallery.css @@ -126,7 +126,7 @@ .gallery-empty-hint { font-size: 0.875rem; - margin-bottom: 1.5rem !important; + margin-bottom: 1.5rem; } /* Lightbox */ @@ -256,6 +256,7 @@ left: 0.5rem; z-index: 10; background: rgba(0, 0, 0, 0.7); + -webkit-backdrop-filter: blur(4px); backdrop-filter: blur(4px); border-radius: 4px; padding: 0.5rem; @@ -279,6 +280,24 @@ gap: 0.5rem; } +.gallery-item-copy-link { + background: rgba(13, 110, 253, 0.9); + color: white; + border: none; + border-radius: 4px; + padding: 0.375rem 0.5rem; + cursor: pointer; + font-size: 1.125rem; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.2s ease; +} + +.gallery-item-copy-link:hover { + background: rgba(13, 110, 253, 1); +} + .gallery-item-delete { background: rgba(220, 53, 69, 0.9); color: white; @@ -320,6 +339,29 @@ background: rgba(220, 53, 69, 1); } +.gallery-lightbox-copy-link { + position: absolute; + top: 1rem; + right: 9rem; + background: rgba(13, 110, 253, 0.9); + border: none; + color: white; + font-size: 1.5rem; + width: 3rem; + height: 3rem; + border-radius: 50%; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.2s ease; + z-index: 10001; +} + +.gallery-lightbox-copy-link:hover { + background: rgba(13, 110, 253, 1); +} + /* Toolbar Styles */ .gallery-toolbar-left { display: flex; @@ -339,6 +381,7 @@ .btn-danger:hover:not(:disabled) { background: rgba(220, 53, 69, 0.2); } + .gallery-item-link { position: absolute; bottom: 0.75rem; diff --git a/apps/client/src/widgets/type_widgets/Gallery.tsx b/apps/client/src/widgets/type_widgets/Gallery.tsx index 9faf42347..1cf6223c1 100644 --- a/apps/client/src/widgets/type_widgets/Gallery.tsx +++ b/apps/client/src/widgets/type_widgets/Gallery.tsx @@ -51,6 +51,7 @@ export default function Gallery({ note }: TypeWidgetProps) { const directAttachments = await note.getAttachments(); const directImageAttachments = directAttachments.filter(a => a.role === "image"); + // Process direct attachments for (const attachment of directImageAttachments) { const size = attachment.contentLength || 0; calculatedTotalSize += size; @@ -71,40 +72,57 @@ export default function Gallery({ note }: TypeWidgetProps) { const childNotes = await note.getChildNotes(); const imageNotes = childNotes.filter(n => n.type === "image"); - // Convert image notes to ImageItem format - for (const imageNote of imageNotes) { + // Process image notes in parallel + const imageBlobPromises = imageNotes.map(async (imageNote) => { const blob = await imageNote.getBlob(); const size = blob?.contentLength || 0; calculatedTotalSize += size; - imageItems.push({ + return { id: imageNote.noteId, url: `api/images/${imageNote.noteId}/${encodeURIComponent(imageNote.title)}`, title: imageNote.title, type: 'note' as const, size - }); - } + }; + }); - // Also check for notes with image attachments - for (const childNote of childNotes) { + const imageNoteItems = await Promise.all(imageBlobPromises); + imageItems.push(...imageNoteItems); + + // Recalculate total size from image note items + imageNoteItems.forEach(item => { + calculatedTotalSize += item.size || 0; + }); + + // Process child note attachments in parallel + const attachmentPromises = childNotes.map(async (childNote) => { const attachments = await childNote.getAttachments(); const imageAttachments = attachments.filter(a => a.role === "image"); - for (const attachment of imageAttachments) { + return imageAttachments.map(attachment => { const size = attachment.contentLength || 0; - calculatedTotalSize += size; - imageItems.push({ + return { id: attachment.attachmentId, url: `api/attachments/${attachment.attachmentId}/image/${encodeURIComponent(attachment.title)}`, title: attachment.title, type: 'attachment' as const, noteId: attachment.ownerId, size - }); - } - } + }; + }); + }); + + const attachmentArrays = await Promise.all(attachmentPromises); + const allAttachments = attachmentArrays.flat(); + + imageItems.push(...allAttachments); + + // Calculate total size from attachments + allAttachments.forEach(item => { + calculatedTotalSize += item.size || 0; + }); } setImages(imageItems); @@ -118,32 +136,14 @@ export default function Gallery({ note }: TypeWidgetProps) { useTriliumEvent("entitiesReloaded", ({ loadResults }) => { const childNoteIds = images.map(img => img.noteId).filter(Boolean); - // Reload if branches change (child notes added/removed or share status changed) - const branchRows = loadResults.getBranchRows(); - if (branchRows.some(b => b.parentNoteId === note.noteId || b.noteId === note.noteId)) { - loadImages(); - return; - } + const shouldReload = + loadResults.getBranchRows().some(b => b.parentNoteId === note.noteId || b.noteId === note.noteId) || + loadResults.getNoteIds().some(id => id === note.noteId || childNoteIds.includes(id)) || + loadResults.getAttachmentRows().some(att => att.ownerId === note.noteId || childNoteIds.includes(att.ownerId)) || + loadResults.getAttributeRows().some(attr => attr.noteId === note.noteId && attr.name === "hideChildAttachments"); - // Reload if any child note changes - const noteIds = loadResults.getNoteIds(); - if (noteIds.some(id => id === note.noteId || childNoteIds.includes(id))) { + if (shouldReload) { loadImages(); - return; - } - - // Reload if attachments change for this note or its children - const attachmentRows = loadResults.getAttachmentRows(); - if (attachmentRows.some(att => att.ownerId === note.noteId || childNoteIds.includes(att.ownerId))) { - loadImages(); - return; - } - - // Reload if attributes change (for hideChildImages label) - const attributeRows = loadResults.getAttributeRows(); - if (attributeRows.some(attr => attr.noteId === note.noteId && attr.name === "hideChildAttachments")) { - loadImages(); - return; } }); @@ -175,24 +175,6 @@ export default function Gallery({ note }: TypeWidgetProps) { // Note: No else clause - we don't do anything for gallery note attachments } - async function handleToggleShare() { - if (isGalleryShared) { - const shareBranch = note.getParentBranches().find((b) => b.parentNoteId === "_share"); - if (shareBranch?.branchId) { - await server.remove(`branches/${shareBranch.branchId}`); - toast.showMessage(t("gallery.unshared_success")); - sync.syncNow(true); - await loadImages(); - } - } else { - // Share the gallery - await branches.cloneNoteToParentNote(note.noteId, "_share"); - toast.showMessage(t("gallery.shared_success")); - sync.syncNow(true); - await loadImages(); - } - } - // Your current handleCopyImageLink is actually fine for both Electron and web async function handleCopyImageLink(img: ImageItem, e: MouseEvent) { e.stopPropagation(); @@ -245,20 +227,6 @@ export default function Gallery({ note }: TypeWidgetProps) { await loadImages(); } - function getShareUrl(noteId: string, syncServerHost: string | null): string | null { - const shareId = note.getOwnedLabelValue("shareAlias") || noteId; - - if (syncServerHost) { - return new URL(`/share/${shareId}`, syncServerHost).href; - } else { - let host = location.host; - if (host.endsWith("/")) { - host = host.substring(0, host.length - 1); - } - return `${location.protocol}//${host}${location.pathname}share/${shareId}`; - } - } - function getAbsoluteUrl(path: string): string { if (syncServerHost) { return new URL(path, syncServerHost).href; @@ -369,22 +337,28 @@ export default function Gallery({ note }: TypeWidgetProps) { } const imagesToDelete = images.filter(img => selectedImages.has(img.id)); + + const deletePromises = imagesToDelete.map(img => { + if (img.type === 'note') { + return server.remove(`notes/${img.id}`); + } else { + return server.remove(`attachments/${img.id}`); + } + }); + + const results = await Promise.allSettled(deletePromises); + let successCount = 0; let errorCount = 0; - for (const img of imagesToDelete) { - try { - if (img.type === 'note') { - await server.remove(`notes/${img.id}`); - } else { - await server.remove(`attachments/${img.id}`); - } + results.forEach((result, index) => { + if (result.status === 'fulfilled') { successCount++; - } catch (error) { - console.error(`Failed to delete image ${img.id}:`, error); + } else { errorCount++; + console.error(`Failed to delete image ${imagesToDelete[index].id}:`, result.reason); } - } + }); if (successCount > 0) { toast.showMessage(t("gallery.delete_multiple_success", { count: successCount })); @@ -483,7 +457,6 @@ export default function Gallery({ note }: TypeWidgetProps) {

{t("gallery.no_images")}

-

{t("gallery.upload_hint")}