diff --git a/apps/client/src/services/in_app_help.ts b/apps/client/src/services/in_app_help.ts index 2f805783a..0efe4aff8 100644 --- a/apps/client/src/services/in_app_help.ts +++ b/apps/client/src/services/in_app_help.ts @@ -1,4 +1,5 @@ import { NoteType } from "@triliumnext/commons"; + import FNote from "../entities/fnote"; import { ViewTypeOptions } from "../widgets/collections/interface"; @@ -24,6 +25,7 @@ export const byNoteType: Record, string | null> = { export const byBookType: Record = { list: "mULW0Q3VojwY", grid: "8QqnMzx393bx", + gallery: null, calendar: "xWbu3jpNWapp", table: "2FvYrpmOXm29", geoMap: "81SGnPGMk7Xc", @@ -39,6 +41,6 @@ export function getHelpUrlForNote(note: FNote | null | undefined) { } else if (note?.hasLabel("textSnippet")) { return "pwc194wlRzcH"; } else if (note && note.type === "book") { - return byBookType[note.getAttributeValue("label", "viewType") as ViewTypeOptions ?? ""] + return byBookType[note.getAttributeValue("label", "viewType") as ViewTypeOptions ?? ""]; } } diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index f112508c1..7276d5da1 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -801,7 +801,8 @@ "board": "Board", "presentation": "Presentation", "include_archived_notes": "Show archived notes", - "hide_child_notes": "Hide child notes in tree" + "hide_child_notes": "Hide child notes in tree", + "gallery": "Gallery" }, "edited_notes": { "no_edited_notes_found": "No edited notes on this day yet...", @@ -2267,5 +2268,27 @@ "pages_other": "{{count}} pages", "pages_alt": "Page {{pageNumber}}", "pages_loading": "Loading..." + }, + "gallery": { + "gallery_created": "Gallery Created", + "new_gallery": "Create New Gallery", + "select_all": "Select All", + "selected": "selected", + "clear_selection": "Clear Selection", + "delete_selected": "Delete Selected", + "delete_multiple": "Delete {{count}} items", + "items_selected": "{{count}} item selected", + "items_selected_other": "{{count}} items selected", + "enter_gallery_name": "Enter new gallery name", + "gallery_creation_failed": "Failed to create new gallery.", + "default_gallery_name": "New Gallery", + "no_image_files": "No image files found.", + "back_to_parent": "Back To Parent", + "image_count": "{{count}} image", + "image_count_other": "{{count}} images", + "gallery_count": "{{count}} gallery", + "gallery_count_other": "{{count}} galleries", + "loading_more": "Loading more images... ({{loaded}} of {{total}})", + "empty_gallery": "This gallery is empty. Upload images to make them appear." } } diff --git a/apps/client/src/widgets/collections/NoteList.tsx b/apps/client/src/widgets/collections/NoteList.tsx index 0b864ace2..c9dcb4a23 100644 --- a/apps/client/src/widgets/collections/NoteList.tsx +++ b/apps/client/src/widgets/collections/NoteList.tsx @@ -36,6 +36,9 @@ const ViewComponents: Record import("./legacy/ListOrGridView.js").then(i => i.GridView)), }, + gallery: { + normal: lazy(() => import("./gallery/index.js")), + }, geoMap: { normal: lazy(() => import("./geomap/index.js")), }, @@ -148,13 +151,12 @@ export function useNoteViewType(note?: FNote | null): ViewTypeOptions | undefine return note.type === "search" ? "list" : "grid"; } return viewType as ViewTypeOptions; - } export function useNoteIds(note: FNote | null | undefined, viewType: ViewTypeOptions | undefined, ntxId: string | null | undefined) { const [ noteIds, setNoteIds ] = useState([]); const [ includeArchived ] = useNoteLabelBoolean(note, "includeArchived"); - const directChildrenOnly = (viewType === "list" || viewType === "grid" || viewType === "table" || note?.type === "search"); + const directChildrenOnly = (viewType === "list" || viewType === "grid" || viewType === "gallery" || viewType === "table" || note?.type === "search"); async function refreshNoteIds() { if (!note) { @@ -169,7 +171,6 @@ export function useNoteIds(note: FNote | null | undefined, viewType: ViewTypeOpt return await note.getChildNoteIdsWithArchiveFiltering(includeArchived); } return await note.getSubtreeNoteIds(includeArchived); - } // Refresh on note switch. diff --git a/apps/client/src/widgets/collections/gallery/index.css b/apps/client/src/widgets/collections/gallery/index.css new file mode 100644 index 000000000..bb4ff3edd --- /dev/null +++ b/apps/client/src/widgets/collections/gallery/index.css @@ -0,0 +1,291 @@ +.gallery-view { + height: 100%; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.gallery-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background-color: var(--accented-background-color); + border-bottom: 1px solid var(--main-border-color); + gap: 12px; + flex-shrink: 0; +} + +.gallery-toolbar-left, +.gallery-toolbar-right { + display: flex; + align-items: center; + gap: 8px; +} + +.gallery-toolbar-status { + font-size: 14px; + color: var(--muted-text-color); +} + +.gallery-toolbar-selection-count { + font-size: 14px; + font-weight: 500; + color: var(--main-text-color); +} + +.gallery-view .note-list-wrapper { + height: 100%; + overflow: auto; + flex: 1; +} + +.gallery-selection-bar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background-color: var(--more-accented-background-color); + border-bottom: 1px solid var(--main-border-color); + font-size: 14px; +} + +.gallery-container { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 12px; + padding: 12px; +} + +.gallery-card { + position: relative; + cursor: pointer; + border-radius: 6px; + overflow: hidden; + background-color: var(--accented-background-color); + transition: transform 0.2s, box-shadow 0.2s; + display: flex; + flex-direction: column; +} + +.gallery-card:hover { + transform: translateY(-2px); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); +} + +.gallery-card.archived { + opacity: 0.5; +} + +.gallery-card-selected { + box-shadow: 0 0 0 3px var(--main-text-color); +} + +.gallery-card-selection-indicator { + position: absolute; + top: 8px; + right: 8px; + z-index: 10; + background-color: var(--main-text-color); + border-radius: 50%; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; +} + +.gallery-card-selection-indicator i { + color: var(--accented-background-color); + font-size: 20px; +} + +.gallery-image-container { + width: 100%; + aspect-ratio: 1; + display: flex; + align-items: center; + justify-content: center; + background-color: var(--main-background-color); + overflow: hidden; + position: relative; +} + +.gallery-image-container img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.gallery-title { + position: absolute; + bottom: 0; + left: 0; + right: 0; + padding: 10px 8px; + text-align: center; + font-weight: 500; + font-size: 12px; + color: white; + background: linear-gradient(to top, rgba(0, 0, 0, 0.8), rgba(0, 0, 0, 0.6)); + word-break: break-word; + line-height: 1.4; + text-overflow: ellipsis; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + opacity: 0; + transition: opacity 0.2s; + z-index: 5; +} + +.gallery-card:hover .gallery-title, +.gallery-card-selected .gallery-title { + opacity: 1; +} + +/* Folder-specific styles */ +.gallery-folder-icon { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background-color: var(--accented-background-color); +} + +.gallery-folder-icon i { + font-size: 48px; + color: var(--main-text-color); + opacity: 0.6; + margin-bottom: 8px; +} + +.gallery-card.gallery-folder:hover .gallery-folder-icon i { + opacity: 0.8; +} + +/* Always show folder names */ +.gallery-folder-icon .gallery-title { + opacity: 1; + position: static; + color: var(--main-text-color); + background: none; + padding: 0; +} + + +.gallery-view .gallery-toolbar .btn-delete.btn.btn-primary { + background-color: var(--dropdown-item-icon-destructive-color); + border-color: var(--dropdown-item-icon-destructive-color); + color: white; +} + +.gallery-view .gallery-toolbar .btn-delete.btn.btn-primary:hover:not(.disabled) { + background-color: color-mix(in srgb, var(--dropdown-item-icon-destructive-color) 80%, black); + border-color: color-mix(in srgb, var(--dropdown-item-icon-destructive-color) 70%, black); +} + +.gallery-view.gallery-drag-over { + position: relative; +} + +.gallery-drop-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(var(--main-background-color-rgb), 0.95); + border: 3px dashed var(--main-border-color); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + pointer-events: none; + backdrop-filter: blur(4px); +} + +.gallery-drop-message { + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; + font-size: 1.5rem; + color: var(--muted-text-color); +} + +.gallery-drop-message i { + font-size: 4rem; +} + +.gallery-item-count { + font-size: 0.85em; + opacity: 0.7; +} + +.gallery-type-badge { + position: absolute; + top: 8px; + left: 8px; + background-color: rgba(0, 0, 0, 0.6); + border-radius: 4px; + padding: 4px 6px; + display: flex; + align-items: center; + justify-content: center; +} + +.gallery-type-badge i { + font-size: 14px; + color: white; +} + +.gallery-share-badge { + position: absolute; + top: 8px; + right: 8px; + background-color: rgba(0, 0, 0, 0.6); + border: none; + border-radius: 4px; + padding: 4px 6px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: background-color 0.2s ease; +} + +.gallery-share-badge:hover { + background-color: rgba(0, 0, 0, 0.8); +} + +.gallery-share-badge i { + font-size: 14px; + color: #4caf50; +} + +.gallery-load-more { + height: 20px; + margin: 20px 0; +} + +.gallery-load-more-text { + text-align: center; + color: var(--muted-text-color); +} + +.gallery-empty-help { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + padding: 48px 24px; + margin: 24px; + border-radius: 8px; + background-color: var(--accented-background-color); + border: 2px dashed var(--main-border-color); + color: var(--muted-text-color); + font-size: 14px; +} diff --git a/apps/client/src/widgets/collections/gallery/index.tsx b/apps/client/src/widgets/collections/gallery/index.tsx new file mode 100644 index 000000000..839b8ace2 --- /dev/null +++ b/apps/client/src/widgets/collections/gallery/index.tsx @@ -0,0 +1,677 @@ +import "./index.css"; + +import { ToggleInParentResponse } from "@triliumnext/commons"; +import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks"; + +import appContext from "../../../components/app_context"; +import type FNote from "../../../entities/fnote"; +import contextMenu from "../../../menus/context_menu"; +import linkContextMenu from "../../../menus/link_context_menu"; +import branches from "../../../services/branches"; +import { copyTextWithToast } from "../../../services/clipboard_ext"; +import froca from "../../../services/froca"; +import { t } from "../../../services/i18n"; +import link from "../../../services/link"; +import noteCreateService from "../../../services/note_create"; +import options from "../../../services/options"; +import server from "../../../services/server"; +import toast from "../../../services/toast"; +import tree from "../../../services/tree"; +import ActionButton from "../../react/ActionButton"; +import Alert from "../../react/Alert"; +import { FormFileUploadActionButton } from "../../react/FormFileUpload"; +import { useTriliumEvent } from "../../react/hooks"; +import type { ViewModeProps } from "../interface"; +import { useFilteredNoteIds } from "../legacy/utils"; + +const INITIAL_LOAD = 50; +const LOAD_MORE_INCREMENT = 50; +const UPLOAD_PROGRESS_TOAST_ID = "gallery-upload-progress"; + +const VISUAL_NOTE_TYPES = ['image', 'canvas', 'mermaid', 'mindMap'] as const; + +function isVisualType(type: string): boolean { + return (VISUAL_NOTE_TYPES as readonly string[]).includes(type); +} + +function isGalleryNote(note: FNote): boolean { + return note.hasLabel('collection') && note.getLabelValue('viewType') === 'gallery'; +} + +function getImageSrc(note: FNote): string | undefined { + switch (note.type) { + case 'image': + return `api/images/${note.noteId}/${encodeURIComponent(note.title)}`; + case 'canvas': + return `api/images/${note.noteId}/canvas.png`; + case 'mermaid': + return `api/images/${note.noteId}/mermaid.svg`; + case 'mindMap': + return `api/images/${note.noteId}/mindmap.svg`; + default: + return undefined; + } +} + +function getShareUrl(note: FNote): string { + const shareId = note.hasOwnedLabel("shareRoot") + ? "" + : note.getOwnedLabelValue("shareAlias") || note.noteId; + + const syncServerHost = options.get("syncServerHost"); + if (syncServerHost) { + return new URL(`/share/${shareId}`, syncServerHost).href; + } + + let host = location.host; + if (host.endsWith("/")) { + host = host.substring(0, host.length - 1); + } + return `${location.protocol}//${host}${location.pathname}share/${shareId}`; +} + +export default function GalleryView({ note, noteIds: unfilteredNoteIds }: ViewModeProps<{}>) { + const noteIds = useFilteredNoteIds(note, unfilteredNoteIds); + const [selectedNoteIds, setSelectedNoteIds] = useState>(new Set()); + const [lastSelectedNoteId, setLastSelectedNoteId] = useState(null); + const [isUploading, setIsUploading] = useState(false); + const [displayCount, setDisplayCount] = useState(INITIAL_LOAD); + const loadMoreRef = useRef(null); + + // Track how many notes actually exist in froca to trigger recalculation when new notes are loaded + const loadedNoteCount = noteIds?.filter(id => froca.notes[id]).length ?? 0; + + // Memoize the filtered and sorted notes to avoid recalculating on every render + const { sortedNotes, imageCount, galleryCount } = useMemo(() => { + const allNotes = noteIds?.map(noteId => froca.notes[noteId]).filter(Boolean) || []; + + const notes = allNotes + .filter(childNote => isGalleryNote(childNote) || isVisualType(childNote.type)) + .sort((a, b) => { + const aIsGallery = isGalleryNote(a); + const bIsGallery = isGalleryNote(b); + + if (aIsGallery && !bIsGallery) return -1; + if (!aIsGallery && bIsGallery) return 1; + return 0; + }); + + const imgCount = notes.filter(note => isVisualType(note.type)).length; + const galCount = notes.filter(note => isGalleryNote(note)).length; + + return { sortedNotes: notes, imageCount: imgCount, galleryCount: galCount }; + }, [noteIds, loadedNoteCount]); + + // Only display a subset of notes for virtual scrolling + const displayedNotes = sortedNotes.slice(0, displayCount); + const hasMore = displayCount < sortedNotes.length; + + // Infinite scroll with IntersectionObserver + useEffect(() => { + if (!hasMore || !loadMoreRef.current) return; + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting) { + setDisplayCount(prev => Math.min(prev + LOAD_MORE_INCREMENT, sortedNotes.length)); + } + }, + { + rootMargin: '300px', // Load before user reaches the bottom + threshold: 0.1 + } + ); + + observer.observe(loadMoreRef.current); + return () => observer.disconnect(); + }, [hasMore, sortedNotes.length]); + + // Reset display count when note changes + useEffect(() => { + setDisplayCount(INITIAL_LOAD); + }, [note.noteId]); + + const toggleSelection = (noteId: string, isCtrlKey: boolean, isShiftKey: boolean) => { + setSelectedNoteIds(prev => { + const newSet = new Set(prev); + + if (isShiftKey && lastSelectedNoteId && sortedNotes) { + const lastIndex = sortedNotes.findIndex(n => n.noteId === lastSelectedNoteId); + const currentIndex = sortedNotes.findIndex(n => n.noteId === noteId); + + if (lastIndex !== -1 && currentIndex !== -1) { + const start = Math.min(lastIndex, currentIndex); + const end = Math.max(lastIndex, currentIndex); + + for (let i = start; i <= end; i++) { + newSet.add(sortedNotes[i].noteId); + } + } + } else if (isCtrlKey) { + if (newSet.has(noteId)) { + newSet.delete(noteId); + } else { + newSet.add(noteId); + } + } else { + newSet.clear(); + newSet.add(noteId); + } + + return newSet; + }); + + if (!isCtrlKey || !selectedNoteIds.has(noteId)) { + setLastSelectedNoteId(noteId); + } + }; + + const handleSelectAll = () => { + if (sortedNotes && sortedNotes.length > 0) { + setSelectedNoteIds(new Set(sortedNotes.map(n => n.noteId))); + setLastSelectedNoteId(sortedNotes[sortedNotes.length - 1].noteId); + } + }; + + const clearSelection = () => { + setSelectedNoteIds(new Set()); + setLastSelectedNoteId(null); + }; + + const handleUpload = async (files: FileList | null) => { + if (!files || files.length === 0 || !note) return; + + setIsUploading(true); + const totalFiles = files.length; + let successCount = 0; + + try { + for (let i = 0; i < totalFiles; i++) { + const file = files[i]; + try { + toast.showPersistent({ + id: UPLOAD_PROGRESS_TOAST_ID, + icon: "bx bx-upload", + message: t("import.in-progress", { progress: `${i + 1}/${totalFiles}` }), + progress: ((i + 1) / totalFiles) * 100 + }); + + const result = await noteCreateService.createNote(note.noteId, { + title: file.name, + type: 'image', + mime: file.type || 'image/png', + content: '', + activate: false + }); + + if (!result?.note) { + toast.showError(t("import.failed", { message: `Failed to create note for ${file.name}` })); + continue; + } + + const uploadResult = await server.upload(`images/${result.note.noteId}`, file); + + if (uploadResult.uploaded) { + successCount++; + } else { + toast.showError(t("import.failed", { message: uploadResult.message || "Unknown error" })); + } + } catch (error) { + console.error(`Failed to upload ${file.name}:`, error); + toast.showError(t("import.failed", { message: file.name })); + } + } + + toast.closePersistent(UPLOAD_PROGRESS_TOAST_ID); + + if (successCount > 0) { + toast.showMessage(t("import.successful")); + } + } finally { + setIsUploading(false); + } + }; + + const handleCreateGallery = async () => { + const newTitle = await new Promise((resolve) => { + appContext.triggerCommand("showPromptDialog", { + title: t("gallery.new_gallery"), + message: t("gallery.enter_gallery_name"), + defaultValue: t("gallery.default_gallery_name"), + callback: resolve + }); + }); + + if (!newTitle) return; + + try { + await noteCreateService.createNote(note.noteId, { + title: newTitle, + type: 'book', + content: '', + templateNoteId: '_template_gallery', + activate: false + }); + + toast.showMessage(t("gallery.gallery_created")); + } catch (error) { + console.error('Failed to create gallery:', error); + toast.showError(t("gallery.gallery_creation_failed")); + } + }; + + const deleteNotes = async (noteIdsToDelete: string[]) => { + if (noteIdsToDelete.length === 0) return; + + const branchIds = noteIdsToDelete + .map(noteId => + froca.notes[noteId] + ?.getParentBranches() + .find(b => b.parentNoteId === note.noteId) + ?.branchId + ) + .filter((branchId): branchId is string => !!branchId); + + if (branchIds.length > 0) { + await branches.deleteNotes(branchIds, false, false); + clearSelection(); + } + }; + + const handleDeleteSelected = async () => { + if (selectedNoteIds.size === 0) return; + await deleteNotes(Array.from(selectedNoteIds)); + }; + + const isEmpty = sortedNotes.length === 0; + + return ( + + ); +} + +interface GalleryToolbarProps { + selectedCount: number; + totalCount: number; + imageCount: number; + galleryCount: number; + isUploading: boolean; + onUpload: (files: FileList | null) => void; + onCreateGallery: () => void; + onSelectAll: () => void; + onClearSelection: () => void; + onDeleteSelected: () => void; +} + +function GalleryToolbar({ + selectedCount, + totalCount, + imageCount, + galleryCount, + isUploading, + onUpload, + onCreateGallery, + onSelectAll, + onClearSelection, + onDeleteSelected, + currentNote +}: GalleryToolbarProps & { currentNote: FNote }) { + + // Check if current note is a gallery with parents + const hasParentGallery = currentNote && currentNote.getParentNotes().some(parent => isGalleryNote(parent)); + + const handleGoBack = () => { + if (currentNote) { + const parentGallery = currentNote.getParentNotes().find(parent => isGalleryNote(parent)); + + if (parentGallery) { + appContext.tabManager.getActiveContext()?.setNote(parentGallery.noteId); + } + } + }; + + const handleCreateGalleryClick = () => { + if (selectedCount > 0) { + onClearSelection(); + } + onCreateGallery(); + }; + + const handleUploadChange = (files: FileList | null) => { + if (selectedCount > 0) { + onClearSelection(); + } + onUpload(files); + }; + + return ( +
+
+ {hasParentGallery && ( + + )} + + + {galleryCount > 0 && ( + + {t("gallery.gallery_count", { count: galleryCount })} + + )} + {imageCount > 0 && ( + + {t("gallery.image_count", { count: imageCount })} + + )} +
+ +
+ {selectedCount === 0 ? ( + + ) : ( + <> + + {t("gallery.items_selected", { count: selectedCount })} + + + + + )} +
+
+ ); +} + +interface GalleryCardProps { + note: FNote; + parentNote: FNote; + isSelected: boolean; + selectedNoteIds: Set; + toggleSelection: (noteId: string, isCtrlKey: boolean, isShiftKey: boolean) => void; + deleteNotes: (noteIdsToDelete: string[]) => Promise; +} + +function GalleryCard({ note, parentNote, isSelected, selectedNoteIds, toggleSelection, deleteNotes }: GalleryCardProps) { + const [noteTitle, setNoteTitle] = useState(); + const [imageSrc, setImageSrc] = useState(); + const [isShared, setIsShared] = useState(() => note.isShared()); + const notePath = getNotePath(parentNote, note); + const isGallery = isGalleryNote(note); + + // Track loaded children to trigger recalculation when notes are loaded into froca + const loadedChildCount = (note.children || []).filter(id => froca.notes[id]).length; + + const childCount = useMemo(() => { + if (!isGallery) { + return 0; + } + + const childNoteIds = note.children || []; + return childNoteIds.filter(childId => { + const child = froca.notes[childId]; + if (!child) return false; + return isVisualType(child.type) || isGalleryNote(child); + }).length; + }, [isGallery, note.children, loadedChildCount]); + + const refreshData = useCallback(() => { + tree.getNoteTitle(note.noteId, parentNote.noteId).then(setNoteTitle); + setImageSrc(getImageSrc(note)); + setIsShared(note.isShared()); + }, [note, parentNote.noteId]); + + useEffect(() => { + refreshData(); + }, [refreshData]); + + useTriliumEvent("entitiesReloaded", ({ loadResults }) => { + if (loadResults.isNoteReloaded(note.noteId)) { + refreshData(); + } + }); + + const handleRename = async () => { + const newTitle = await new Promise((resolve) => { + appContext.triggerCommand("showPromptDialog", { + title: t("rename_note.rename_note"), + message: t("rename_note.rename_note_title_to"), + defaultValue: note.title, + callback: resolve + }); + }); + + if (newTitle && newTitle !== note.title) { + await server.put(`notes/${note.noteId}/title`, { title: newTitle }); + setNoteTitle(newTitle); + } + }; + + const handleToggleShare = async (noteToShare: FNote) => { + const shouldShare = !noteToShare.isShared(); + const resp = await server.put(`notes/${noteToShare.noteId}/toggle-in-parent/_share/${shouldShare}`); + + if (!resp.success && "message" in resp) { + toast.showError(resp.message); + } else { + setIsShared(shouldShare); + } + }; + + const handleClick = (e: MouseEvent) => { + if (e.ctrlKey || e.metaKey) { + e.preventDefault(); + e.stopPropagation(); + toggleSelection(note.noteId, true, false); + } else if (e.shiftKey) { + e.preventDefault(); + e.stopPropagation(); + toggleSelection(note.noteId, false, true); + } else if (selectedNoteIds.size > 0) { + e.preventDefault(); + e.stopPropagation(); + toggleSelection(note.noteId, false, false); + } else { + link.goToLink(e); + } + }; + + const handleContextMenu = (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + if (!isSelected && selectedNoteIds.size > 0) { + toggleSelection(note.noteId, false, false); + } + + const noteIdsToDelete = selectedNoteIds.size > 0 && isSelected + ? Array.from(selectedNoteIds) + : [note.noteId]; + + const isBulkOperation = noteIdsToDelete.length > 1; + + contextMenu.show({ + x: e.pageX, + y: e.pageY, + items: [ + { title: t("link_context_menu.open_note_in_new_window"), command: "openNoteInNewWindow", uiIcon: "bx bx-window-open" }, + { kind: "separator" }, + { + title: t("rename_note.rename_note"), + uiIcon: "bx bx-rename", + enabled: !isBulkOperation, + handler: handleRename + }, + { kind: "separator" }, + { + title: isShared + ? t("shared_switch.toggle-off-title") + : t("shared_switch.toggle-on-title"), + uiIcon: isShared ? "bx bx-unlink" : "bx bx-share-alt", + enabled: !isBulkOperation, + handler: () => handleToggleShare(note) + }, + { kind: "separator" }, + { + title: isBulkOperation + ? t("gallery.delete_multiple", { count: noteIdsToDelete.length }) + : t("note_actions.delete_note"), + uiIcon: "bx bx-trash", + handler: () => deleteNotes(noteIdsToDelete) + } + ], + selectMenuItemHandler: ({ command }) => { + if (command) { + linkContextMenu.handleLinkContextMenuItem(command, e, notePath); + } + } + }); + }; + + const handleShareBadgeClick = (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + const shareUrl = getShareUrl(note); + copyTextWithToast(shareUrl); + }; + + return ( +
handleClick(e)} + onContextMenu={(e) => handleContextMenu(e)} + > + {isSelected && ( +
+ +
+ )} + {isGallery ? ( +
+ + {isShared && ( + + )} +
+ {noteTitle} + {childCount > 0 && ( + ({childCount}) + )} +
+
+ ) : imageSrc ? ( +
+ {noteTitle} +
+ +
+ {isShared && ( + + )} +
{noteTitle}
+
+ ) : null} +
+ ); +} + +function getNotePath(parentNote: FNote, childNote: FNote) { + return parentNote.type === "search" ? childNote.noteId : `${parentNote.noteId}/${childNote.noteId}`; +} diff --git a/apps/client/src/widgets/collections/interface.ts b/apps/client/src/widgets/collections/interface.ts index 4a965588d..303fcfee4 100644 --- a/apps/client/src/widgets/collections/interface.ts +++ b/apps/client/src/widgets/collections/interface.ts @@ -1,7 +1,7 @@ import FNote from "../../entities/fnote"; import type { PrintReport } from "../../print"; -export const allViewTypes = ["list", "grid", "calendar", "table", "geoMap", "board", "presentation"] as const; +export const allViewTypes = ["list", "grid", "gallery", "calendar", "table", "geoMap", "board", "presentation"] as const; export type ViewTypeOptions = typeof allViewTypes[number]; export type ViewModeMedia = "screen" | "print"; diff --git a/apps/client/src/widgets/note_bars/CollectionProperties.tsx b/apps/client/src/widgets/note_bars/CollectionProperties.tsx index d5c9ffb42..f8e4f2432 100644 --- a/apps/client/src/widgets/note_bars/CollectionProperties.tsx +++ b/apps/client/src/widgets/note_bars/CollectionProperties.tsx @@ -21,6 +21,7 @@ import { useViewType, VIEW_TYPE_MAPPINGS } from "../ribbon/CollectionPropertiesT const ICON_MAPPINGS: Record = { grid: "bx bxs-grid", list: "bx bx-list-ul", + gallery: "bx bx-images", calendar: "bx bx-calendar", table: "bx bx-table", geoMap: "bx bx-map-alt", diff --git a/apps/client/src/widgets/react/FormFileUpload.tsx b/apps/client/src/widgets/react/FormFileUpload.tsx index e97e73184..9e59b5dfe 100644 --- a/apps/client/src/widgets/react/FormFileUpload.tsx +++ b/apps/client/src/widgets/react/FormFileUpload.tsx @@ -3,16 +3,16 @@ import { useEffect, useRef } from "preact/hooks"; import ActionButton, { ActionButtonProps } from "./ActionButton"; import Button, { ButtonProps } from "./Button"; - interface FormFileUploadProps { name?: string; onChange: (files: FileList | null) => void; multiple?: boolean; hidden?: boolean; inputRef?: Ref; + accept?: string; } -export default function FormFileUpload({ inputRef, name, onChange, multiple, hidden }: FormFileUploadProps) { +export default function FormFileUpload({ inputRef, name, onChange, multiple, hidden, accept }: FormFileUploadProps) { // Prevent accidental reuse of a file selected in a previous instance of the upload form. useEffect(() => { onChange(null); @@ -26,6 +26,7 @@ export default function FormFileUpload({ inputRef, name, onChange, multiple, hid type="file" class="form-control-file" multiple={multiple} + accept={accept} onChange={e => onChange((e.target as HTMLInputElement).files)} /> ); @@ -58,7 +59,7 @@ export function FormFileUploadButton({ onChange, ...buttonProps }: Omit & Pick) { +export function FormFileUploadActionButton({ onChange, multiple, accept, ...buttonProps }: Omit & Pick) { const inputRef = useRef(null); return ( @@ -70,6 +71,8 @@ export function FormFileUploadActionButton({ onChange, ...buttonProps }: Omit