From 0e492cd45f272ba22f15923c3a92ec472924d0b7 Mon Sep 17 00:00:00 2001 From: lzinga Date: Mon, 22 Dec 2025 14:49:48 -0800 Subject: [PATCH 1/9] feat: add gallery view type and implement basic gallery functionality --- .../src/translations/en/translation.json | 22 +- .../src/widgets/collections/NoteList.tsx | 7 +- .../src/widgets/collections/gallery/index.css | 225 +++++++ .../src/widgets/collections/gallery/index.tsx | 572 ++++++++++++++++++ .../src/widgets/collections/interface.ts | 2 +- .../note_bars/CollectionProperties.tsx | 1 + .../src/widgets/react/FormFileUpload.tsx | 9 +- .../ribbon/CollectionPropertiesTab.tsx | 154 ++--- .../ribbon/collection-properties-config.tsx | 18 +- apps/client/src/widgets/type_widgets/Book.tsx | 12 +- .../src/services/hidden_subtree_templates.ts | 21 + 11 files changed, 947 insertions(+), 96 deletions(-) create mode 100644 apps/client/src/widgets/collections/gallery/index.css create mode 100644 apps/client/src/widgets/collections/gallery/index.tsx diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index 652ad0725..a8ccb96cd 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...", @@ -2263,5 +2264,24 @@ "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.", + "drop_images_here": "Drop Images Here", + "back_to_parent": "Back To Parent", + "image_count": "{{count}} image", + "image_count_other": "{{count}} images" } } diff --git a/apps/client/src/widgets/collections/NoteList.tsx b/apps/client/src/widgets/collections/NoteList.tsx index ec80e6b19..b2ceeac44 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")), }, @@ -146,13 +149,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) { @@ -167,7 +169,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..043a34aa6 --- /dev/null +++ b/apps/client/src/widgets/collections/gallery/index.css @@ -0,0 +1,225 @@ +.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-toolbar .btn-delete.btn.btn-primary { + background-color: var(--dropdown-item-icon-destructive-color) !important; + border-color: var(--dropdown-item-icon-destructive-color) !important; + color: white !important; +} + +.gallery-toolbar .btn-delete.btn.btn-primary:hover:not(.disabled) { + background-color: color-mix(in srgb, var(--dropdown-item-icon-destructive-color) 80%, black) !important; + border-color: color-mix(in srgb, var(--dropdown-item-icon-destructive-color) 70%, black) !important; +} + +.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; +} 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..d0b0aedf0 --- /dev/null +++ b/apps/client/src/widgets/collections/gallery/index.tsx @@ -0,0 +1,572 @@ +import "./index.css"; + +import { useEffect, 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 froca from "../../../services/froca"; +import { t } from "../../../services/i18n"; +import link from "../../../services/link"; +import noteCreateService from "../../../services/note_create"; +import server from "../../../services/server"; +import toast from "../../../services/toast"; +import tree from "../../../services/tree"; +import ActionButton from "../../react/ActionButton"; +import { FormFileUploadActionButton } from "../../react/FormFileUpload"; +import type { ViewModeProps } from "../interface"; +import { useFilteredNoteIds } from "../legacy/utils"; + +const INITIAL_LOAD = 50; +const LOAD_MORE_INCREMENT = 50; + +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); + + // Get all notes (no pagination) + const allNotes = noteIds?.map(noteId => froca.notes[noteId]).filter(Boolean) || []; + + // Filter and sort notes: galleries first, then images + const sortedNotes = allNotes + .filter(childNote => { + const isGallery = childNote.hasLabel('collection') && childNote.getLabelValue('viewType') === 'gallery'; + const isImage = childNote.type === 'image' || childNote.type === 'canvas'; + return isGallery || isImage; + }) + .sort((a, b) => { + const aIsGallery = a.hasLabel('collection') && a.getLabelValue('viewType') === 'gallery'; + const bIsGallery = b.hasLabel('collection') && b.getLabelValue('viewType') === 'gallery'; + + if (aIsGallery && !bIsGallery) return -1; + if (!aIsGallery && bIsGallery) return 1; + return 0; + }); + + const imageCount = sortedNotes.filter(note => + note.type === 'image' || note.type === 'canvas' + ).length; + + // 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; + const toastId = "gallery-upload-progress"; + + try { + for (let i = 0; i < totalFiles; i++) { + const file = files[i]; + try { + toast.showPersistent({ + id: toastId, + 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(toastId); + + 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 handleDeleteSelected = async () => { + if (selectedNoteIds.size === 0) return; + + const branchIds: string[] = []; + for (const noteId of selectedNoteIds) { + const noteToDelete = froca.notes[noteId]; + if (noteToDelete) { + const branch = noteToDelete.getParentBranches().find(b => b.parentNoteId === note.noteId); + if (branch) { + branchIds.push(branch.branchId); + } + } + } + + if (branchIds.length > 0) { + await branches.deleteNotes(branchIds, false, false); + clearSelection(); + } + }; + + return ( + + ); +} + +interface GalleryToolbarProps { + selectedCount: number; + totalCount: number; + imageCount: number; + isUploading: boolean; + onUpload: (files: FileList | null) => void; + onCreateGallery: () => void; + onSelectAll: () => void; + onClearSelection: () => void; + onDeleteSelected: () => void; +} + +function GalleryToolbar({ + selectedCount, + totalCount, + imageCount, + 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 => + parent.hasLabel('collection') && parent.getLabelValue('viewType') === 'gallery' + ); + + const handleGoBack = () => { + if (currentNote) { + const parentGallery = currentNote.getParentNotes().find(parent => + parent.hasLabel('collection') && parent.getLabelValue('viewType') === 'gallery' + ); + + 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 && ( + + )} + + + {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; + clearSelection: () => void; +} + +function GalleryCard({ note, parentNote, isSelected, selectedNoteIds, toggleSelection, clearSelection }: GalleryCardProps) { + const [noteTitle, setNoteTitle] = useState(); + const [imageSrc, setImageSrc] = useState(); + const notePath = getNotePath(parentNote, note); + const isGallery = note.hasLabel('collection') && note.getLabelValue('viewType') === 'gallery'; + + const childCount = isGallery ? (() => { + const childNoteIds = note.children || []; + return childNoteIds.filter(childId => { + const child = froca.notes[childId]; + if (!child) return false; + + return child.type === 'image' || + child.type === 'canvas' || + (child.hasLabel('collection') && child.getLabelValue('viewType') === 'gallery'); + }).length; + })() : 0; + + useEffect(() => { + tree.getNoteTitle(note.noteId, parentNote.noteId).then(setNoteTitle); + + if (note.type === 'image') { + setImageSrc(`api/images/${note.noteId}/${encodeURIComponent(note.title)}`); + } else if (note.type === 'canvas') { + setImageSrc(`api/images/${note.noteId}/canvas.png`); + } + }, [note, parentNote.noteId]); + + 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 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 notesToDelete = selectedNoteIds.size > 0 && isSelected + ? Array.from(selectedNoteIds) + : [note.noteId]; + + const isBulkOperation = notesToDelete.length > 1; + + const handleDelete = async () => { + const branchIds: string[] = []; + for (const noteId of notesToDelete) { + const noteToDelete = froca.notes[noteId]; + if (noteToDelete) { + const branch = noteToDelete.getParentBranches().find(b => b.parentNoteId === parentNote.noteId); + if (branch) { + branchIds.push(branch.branchId); + } + } + } + + if (branchIds.length > 0) { + await branches.deleteNotes(branchIds, false, false); + clearSelection(); + } + }; + + 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: isBulkOperation + ? t("gallery.delete_multiple", { count: notesToDelete.length }) + : t("note_actions.delete_note"), + uiIcon: "bx bx-trash", + handler: handleDelete + } + ], + selectMenuItemHandler: ({ command }) => { + if (command) { + linkContextMenu.handleLinkContextMenuItem(command, e, notePath); + } + } + }); + }; + + return ( +
handleClick(e)} + onContextMenu={(e) => handleContextMenu(e)} + > + {isSelected && ( +
+ +
+ )} + {isGallery ? ( +
+ +
+ {noteTitle} + {childCount > 0 && ( + ({childCount}) + )} +
+
+ ) : imageSrc ? ( +
+ {noteTitle} +
{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