mirror of
https://github.com/zadam/trilium.git
synced 2026-01-23 06:54:28 +01:00
Merge dde5db14428fa3b7de9e6ed27a0ac7ad2e129329 into 17f3ffd00cb120ebe5d634e8dad97f85fc391144
This commit is contained in:
commit
b488a37c08
@ -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<Exclude<NoteType, "book">, string | null> = {
|
||||
export const byBookType: Record<ViewTypeOptions, string | null> = {
|
||||
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 ?? ""];
|
||||
}
|
||||
}
|
||||
|
||||
@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@ -36,6 +36,9 @@ const ViewComponents: Record<ViewTypeOptions, { normal: LazyLoadedComponent, pri
|
||||
grid: {
|
||||
normal: lazy(() => 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<string[]>([]);
|
||||
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.
|
||||
|
||||
291
apps/client/src/widgets/collections/gallery/index.css
Normal file
291
apps/client/src/widgets/collections/gallery/index.css
Normal file
@ -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;
|
||||
}
|
||||
677
apps/client/src/widgets/collections/gallery/index.tsx
Normal file
677
apps/client/src/widgets/collections/gallery/index.tsx
Normal file
@ -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<Set<string>>(new Set());
|
||||
const [lastSelectedNoteId, setLastSelectedNoteId] = useState<string | null>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [displayCount, setDisplayCount] = useState(INITIAL_LOAD);
|
||||
const loadMoreRef = useRef<HTMLDivElement>(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<string | null>((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 (
|
||||
<div class="note-list gallery-view">
|
||||
<GalleryToolbar
|
||||
selectedCount={selectedNoteIds.size}
|
||||
totalCount={sortedNotes?.length ?? 0}
|
||||
imageCount={imageCount}
|
||||
galleryCount={galleryCount}
|
||||
isUploading={isUploading}
|
||||
currentNote={note}
|
||||
onUpload={handleUpload}
|
||||
onCreateGallery={handleCreateGallery}
|
||||
onSelectAll={handleSelectAll}
|
||||
onClearSelection={clearSelection}
|
||||
onDeleteSelected={handleDeleteSelected}
|
||||
/>
|
||||
|
||||
{isEmpty && (
|
||||
<Alert type="info" className="gallery-empty-help">
|
||||
{t("gallery.empty_gallery")}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div class="note-list-wrapper">
|
||||
<div class="gallery-container">
|
||||
{displayedNotes.map(childNote => (
|
||||
<GalleryCard
|
||||
key={childNote.noteId}
|
||||
note={childNote}
|
||||
parentNote={note}
|
||||
isSelected={selectedNoteIds.has(childNote.noteId)}
|
||||
selectedNoteIds={selectedNoteIds}
|
||||
toggleSelection={toggleSelection}
|
||||
deleteNotes={deleteNotes}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Infinite scroll trigger */}
|
||||
{hasMore && (
|
||||
<div
|
||||
ref={loadMoreRef}
|
||||
className="gallery-load-more"
|
||||
>
|
||||
<div className="gallery-load-more-text">
|
||||
{t('gallery.loading_more', { loaded: displayedNotes.length, total: sortedNotes.length })}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="gallery-toolbar">
|
||||
<div className="gallery-toolbar-left">
|
||||
{hasParentGallery && (
|
||||
<ActionButton
|
||||
icon="bx bx-arrow-back"
|
||||
text={t("gallery.back_to_parent")}
|
||||
frame
|
||||
onClick={handleGoBack}
|
||||
/>
|
||||
)}
|
||||
<ActionButton
|
||||
icon="bx bx-folder-plus"
|
||||
text={t("gallery.new_gallery")}
|
||||
frame
|
||||
onClick={handleCreateGalleryClick}
|
||||
/>
|
||||
<FormFileUploadActionButton
|
||||
icon="bx bx-upload"
|
||||
text={t("upload_attachments.upload")}
|
||||
frame
|
||||
disabled={isUploading}
|
||||
onChange={handleUploadChange}
|
||||
multiple
|
||||
accept="image/*"
|
||||
/>
|
||||
{galleryCount > 0 && (
|
||||
<span className="gallery-toolbar-status">
|
||||
{t("gallery.gallery_count", { count: galleryCount })}
|
||||
</span>
|
||||
)}
|
||||
{imageCount > 0 && (
|
||||
<span className="gallery-toolbar-status">
|
||||
{t("gallery.image_count", { count: imageCount })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="gallery-toolbar-right">
|
||||
{selectedCount === 0 ? (
|
||||
<ActionButton
|
||||
icon="bx bx-select-multiple"
|
||||
text={t("gallery.select_all")}
|
||||
frame
|
||||
onClick={onSelectAll}
|
||||
disabled={totalCount === 0}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<span className="gallery-toolbar-selection-count">
|
||||
{t("gallery.items_selected", { count: selectedCount })}
|
||||
</span>
|
||||
<ActionButton
|
||||
icon="bx bx-x"
|
||||
text={t("gallery.clear_selection")}
|
||||
frame
|
||||
onClick={onClearSelection}
|
||||
/>
|
||||
<ActionButton
|
||||
icon="bx bx-trash"
|
||||
text={t("gallery.delete_selected")}
|
||||
frame
|
||||
className="btn-delete"
|
||||
onClick={onDeleteSelected}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface GalleryCardProps {
|
||||
note: FNote;
|
||||
parentNote: FNote;
|
||||
isSelected: boolean;
|
||||
selectedNoteIds: Set<string>;
|
||||
toggleSelection: (noteId: string, isCtrlKey: boolean, isShiftKey: boolean) => void;
|
||||
deleteNotes: (noteIdsToDelete: string[]) => Promise<void>;
|
||||
}
|
||||
|
||||
function GalleryCard({ note, parentNote, isSelected, selectedNoteIds, toggleSelection, deleteNotes }: GalleryCardProps) {
|
||||
const [noteTitle, setNoteTitle] = useState<string>();
|
||||
const [imageSrc, setImageSrc] = useState<string>();
|
||||
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<string | null>((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<ToggleInParentResponse>(`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 (
|
||||
<div
|
||||
className={`gallery-card block-link ${note.isArchived ? "archived" : ""} ${isGallery ? "gallery-folder" : ""} ${isSelected ? "gallery-card-selected" : ""}`}
|
||||
data-href={`#${notePath}`}
|
||||
data-note-id={note.noteId}
|
||||
onClick={(e) => handleClick(e)}
|
||||
onContextMenu={(e) => handleContextMenu(e)}
|
||||
>
|
||||
{isSelected && (
|
||||
<div className="gallery-card-selection-indicator">
|
||||
<i className="bx bx-check-circle" />
|
||||
</div>
|
||||
)}
|
||||
{isGallery ? (
|
||||
<div className="gallery-image-container gallery-folder-icon">
|
||||
<i className="bx bx-folder" />
|
||||
{isShared && (
|
||||
<button
|
||||
type="button"
|
||||
className="gallery-share-badge"
|
||||
title={t("breadcrumb_badges.shared_copy_to_clipboard")}
|
||||
onClick={handleShareBadgeClick}
|
||||
>
|
||||
<i className="bx bx-share-alt" />
|
||||
</button>
|
||||
)}
|
||||
<div className="gallery-title">
|
||||
{noteTitle}
|
||||
{childCount > 0 && (
|
||||
<span className="gallery-item-count"> ({childCount})</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : imageSrc ? (
|
||||
<div className="gallery-image-container">
|
||||
<img src={imageSrc} alt={noteTitle} loading="lazy" />
|
||||
<div className="gallery-type-badge">
|
||||
<i className={note.getIcon()} />
|
||||
</div>
|
||||
{isShared && (
|
||||
<button
|
||||
type="button"
|
||||
className="gallery-share-badge"
|
||||
title={t("breadcrumb_badges.shared_copy_to_clipboard")}
|
||||
onClick={handleShareBadgeClick}
|
||||
>
|
||||
<i className="bx bx-share-alt" />
|
||||
</button>
|
||||
)}
|
||||
<div className="gallery-title">{noteTitle}</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getNotePath(parentNote: FNote, childNote: FNote) {
|
||||
return parentNote.type === "search" ? childNote.noteId : `${parentNote.noteId}/${childNote.noteId}`;
|
||||
}
|
||||
@ -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";
|
||||
|
||||
@ -21,6 +21,7 @@ import { useViewType, VIEW_TYPE_MAPPINGS } from "../ribbon/CollectionPropertiesT
|
||||
const ICON_MAPPINGS: Record<ViewTypeOptions, string> = {
|
||||
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",
|
||||
|
||||
@ -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<HTMLInputElement>;
|
||||
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)} />
|
||||
</label>
|
||||
);
|
||||
@ -58,7 +59,7 @@ export function FormFileUploadButton({ onChange, ...buttonProps }: Omit<ButtonPr
|
||||
* Similar to {@link FormFileUploadButton}, but uses an {@link ActionButton} instead of a normal {@link Button}.
|
||||
* @param param the change listener for the file upload and the properties for the button.
|
||||
*/
|
||||
export function FormFileUploadActionButton({ onChange, ...buttonProps }: Omit<ActionButtonProps, "onClick"> & Pick<FormFileUploadProps, "onChange">) {
|
||||
export function FormFileUploadActionButton({ onChange, multiple, accept, ...buttonProps }: Omit<ActionButtonProps, "onClick"> & Pick<FormFileUploadProps, "onChange" | "multiple" | "accept">) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
return (
|
||||
@ -70,6 +71,8 @@ export function FormFileUploadActionButton({ onChange, ...buttonProps }: Omit<Ac
|
||||
<FormFileUpload
|
||||
inputRef={inputRef}
|
||||
hidden
|
||||
multiple={multiple}
|
||||
accept={accept}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</>
|
||||
|
||||
@ -1,22 +1,24 @@
|
||||
import { useContext, useMemo } from "preact/hooks";
|
||||
import { t } from "../../services/i18n";
|
||||
import FormSelect, { FormSelectWithGroups } from "../react/FormSelect";
|
||||
import { TabContext } from "./ribbon-interface";
|
||||
import { mapToKeyValueArray } from "../../services/utils";
|
||||
import { useNoteLabel, useNoteLabelBoolean } from "../react/hooks";
|
||||
import { bookPropertiesConfig, BookProperty, ButtonProperty, CheckBoxProperty, ComboBoxProperty, NumberProperty, SplitButtonProperty } from "./collection-properties-config";
|
||||
import Button, { SplitButton } from "../react/Button";
|
||||
import { ParentComponent } from "../react/react_utils";
|
||||
import FNote from "../../entities/fnote";
|
||||
import FormCheckbox from "../react/FormCheckbox";
|
||||
import FormTextBox from "../react/FormTextBox";
|
||||
import { ComponentChildren } from "preact";
|
||||
import { ViewTypeOptions } from "../collections/interface";
|
||||
import { useContext, useMemo } from "preact/hooks";
|
||||
|
||||
import FNote from "../../entities/fnote";
|
||||
import { isExperimentalFeatureEnabled } from "../../services/experimental_features";
|
||||
import { t } from "../../services/i18n";
|
||||
import { mapToKeyValueArray } from "../../services/utils";
|
||||
import { ViewTypeOptions } from "../collections/interface";
|
||||
import Button, { SplitButton } from "../react/Button";
|
||||
import FormCheckbox from "../react/FormCheckbox";
|
||||
import FormSelect, { FormSelectWithGroups } from "../react/FormSelect";
|
||||
import FormTextBox from "../react/FormTextBox";
|
||||
import { useNoteLabel, useNoteLabelBoolean } from "../react/hooks";
|
||||
import { ParentComponent } from "../react/react_utils";
|
||||
import { bookPropertiesConfig, BookProperty, ButtonProperty, CheckBoxProperty, ComboBoxProperty, NumberProperty, SplitButtonProperty } from "./collection-properties-config";
|
||||
import { TabContext } from "./ribbon-interface";
|
||||
|
||||
export const VIEW_TYPE_MAPPINGS: Record<ViewTypeOptions, string> = {
|
||||
grid: t("book_properties.grid"),
|
||||
list: t("book_properties.list"),
|
||||
gallery: t("book_properties.gallery"),
|
||||
calendar: t("book_properties.calendar"),
|
||||
table: t("book_properties.table"),
|
||||
geoMap: t("book_properties.geo-map"),
|
||||
@ -50,70 +52,70 @@ export function useViewType(note: FNote | null | undefined) {
|
||||
}
|
||||
|
||||
function CollectionTypeSwitcher({ viewType, setViewType }: { viewType: string, setViewType: (newValue: string) => void }) {
|
||||
const collectionTypes = useMemo(() => mapToKeyValueArray(VIEW_TYPE_MAPPINGS), []);
|
||||
const collectionTypes = useMemo(() => mapToKeyValueArray(VIEW_TYPE_MAPPINGS), []);
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "baseline" }}>
|
||||
<span style={{ whiteSpace: "nowrap" }}>{t("book_properties.view_type")}: </span>
|
||||
<FormSelect
|
||||
currentValue={viewType ?? "grid"} onChange={setViewType}
|
||||
values={collectionTypes}
|
||||
keyProperty="key" titleProperty="value"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "baseline" }}>
|
||||
<span style={{ whiteSpace: "nowrap" }}>{t("book_properties.view_type")}: </span>
|
||||
<FormSelect
|
||||
currentValue={viewType ?? "grid"} onChange={setViewType}
|
||||
values={collectionTypes}
|
||||
keyProperty="key" titleProperty="value"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BookProperties({ viewType, note, properties }: { viewType: ViewTypeOptions, note: FNote, properties: BookProperty[] }) {
|
||||
return (
|
||||
<>
|
||||
{properties.map(property => (
|
||||
<div className={`type-${property}`}>
|
||||
{mapPropertyView({ note, property })}
|
||||
</div>
|
||||
))}
|
||||
return (
|
||||
<>
|
||||
{properties.map(property => (
|
||||
<div className={`type-${property}`}>
|
||||
{mapPropertyView({ note, property })}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<CheckboxPropertyView
|
||||
note={note} property={{
|
||||
bindToLabel: "includeArchived",
|
||||
label: t("book_properties.include_archived_notes"),
|
||||
type: "checkbox"
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
<CheckboxPropertyView
|
||||
note={note} property={{
|
||||
bindToLabel: "includeArchived",
|
||||
label: t("book_properties.include_archived_notes"),
|
||||
type: "checkbox"
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function mapPropertyView({ note, property }: { note: FNote, property: BookProperty }) {
|
||||
switch (property.type) {
|
||||
case "button":
|
||||
return <ButtonPropertyView note={note} property={property} />
|
||||
case "split-button":
|
||||
return <SplitButtonPropertyView note={note} property={property} />
|
||||
case "checkbox":
|
||||
return <CheckboxPropertyView note={note} property={property} />
|
||||
case "number":
|
||||
return <NumberPropertyView note={note} property={property} />
|
||||
case "combobox":
|
||||
return <ComboBoxPropertyView note={note} property={property} />
|
||||
}
|
||||
switch (property.type) {
|
||||
case "button":
|
||||
return <ButtonPropertyView note={note} property={property} />;
|
||||
case "split-button":
|
||||
return <SplitButtonPropertyView note={note} property={property} />;
|
||||
case "checkbox":
|
||||
return <CheckboxPropertyView note={note} property={property} />;
|
||||
case "number":
|
||||
return <NumberPropertyView note={note} property={property} />;
|
||||
case "combobox":
|
||||
return <ComboBoxPropertyView note={note} property={property} />;
|
||||
}
|
||||
}
|
||||
|
||||
function ButtonPropertyView({ note, property }: { note: FNote, property: ButtonProperty }) {
|
||||
const parentComponent = useContext(ParentComponent);
|
||||
const parentComponent = useContext(ParentComponent);
|
||||
|
||||
return <Button
|
||||
text={property.label}
|
||||
title={property.title}
|
||||
icon={property.icon}
|
||||
onClick={() => {
|
||||
if (!parentComponent) return;
|
||||
property.onClick({
|
||||
note,
|
||||
triggerCommand: parentComponent.triggerCommand.bind(parentComponent)
|
||||
});
|
||||
}}
|
||||
/>
|
||||
return <Button
|
||||
text={property.label}
|
||||
title={property.title}
|
||||
icon={property.icon}
|
||||
onClick={() => {
|
||||
if (!parentComponent) return;
|
||||
property.onClick({
|
||||
note,
|
||||
triggerCommand: parentComponent.triggerCommand.bind(parentComponent)
|
||||
});
|
||||
}}
|
||||
/>;
|
||||
}
|
||||
|
||||
function SplitButtonPropertyView({ note, property }: { note: FNote, property: SplitButtonProperty }) {
|
||||
@ -131,18 +133,18 @@ function SplitButtonPropertyView({ note, property }: { note: FNote, property: Sp
|
||||
onClick={() => clickContext && property.onClick(clickContext)}
|
||||
>
|
||||
{parentComponent && <ItemsComponent note={note} parentComponent={parentComponent} />}
|
||||
</SplitButton>
|
||||
</SplitButton>;
|
||||
}
|
||||
|
||||
function CheckboxPropertyView({ note, property }: { note: FNote, property: CheckBoxProperty }) {
|
||||
const [ value, setValue ] = useNoteLabelBoolean(note, property.bindToLabel);
|
||||
const [ value, setValue ] = useNoteLabelBoolean(note, property.bindToLabel);
|
||||
|
||||
return (
|
||||
<FormCheckbox
|
||||
label={property.label}
|
||||
currentValue={value} onChange={setValue}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<FormCheckbox
|
||||
label={property.label}
|
||||
currentValue={value} onChange={setValue}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function NumberPropertyView({ note, property }: { note: FNote, property: NumberProperty }) {
|
||||
@ -160,7 +162,7 @@ function NumberPropertyView({ note, property }: { note: FNote, property: NumberP
|
||||
disabled={disabled}
|
||||
/>
|
||||
</LabelledEntry>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function ComboBoxPropertyView({ note, property }: { note: FNote, property: ComboBoxProperty }) {
|
||||
@ -174,7 +176,7 @@ function ComboBoxPropertyView({ note, property }: { note: FNote, property: Combo
|
||||
currentValue={value ?? property.defaultValue} onChange={setValue}
|
||||
/>
|
||||
</LabelledEntry>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function LabelledEntry({ label, children }: { label: string, children: ComponentChildren }) {
|
||||
@ -186,5 +188,5 @@ function LabelledEntry({ label, children }: { label: string, children: Component
|
||||
{children}
|
||||
</label>
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,15 +1,16 @@
|
||||
import { FilterLabelsByType } from "@triliumnext/commons";
|
||||
import { t } from "i18next";
|
||||
import { VNode } from "preact";
|
||||
|
||||
import Component from "../../components/component";
|
||||
import FNote from "../../entities/fnote";
|
||||
import attributes from "../../services/attributes";
|
||||
import NoteContextAwareWidget from "../note_context_aware_widget";
|
||||
import { DEFAULT_MAP_LAYER_NAME, MAP_LAYERS, type MapLayer } from "../collections/geomap/map_layer";
|
||||
import { ViewTypeOptions } from "../collections/interface";
|
||||
import { FilterLabelsByType } from "@triliumnext/commons";
|
||||
import { DEFAULT_THEME, getPresentationThemes } from "../collections/presentation/themes";
|
||||
import { VNode } from "preact";
|
||||
import { useNoteLabel } from "../react/hooks";
|
||||
import NoteContextAwareWidget from "../note_context_aware_widget";
|
||||
import { FormDropdownDivider, FormListItem } from "../react/FormList";
|
||||
import Component from "../../components/component";
|
||||
import { useNoteLabel } from "../react/hooks";
|
||||
|
||||
interface BookConfig {
|
||||
properties: BookProperty[];
|
||||
@ -78,6 +79,9 @@ export const bookPropertiesConfig: Record<ViewTypeOptions, BookConfig> = {
|
||||
grid: {
|
||||
properties: []
|
||||
},
|
||||
gallery: {
|
||||
properties: []
|
||||
},
|
||||
list: {
|
||||
properties: [
|
||||
{
|
||||
@ -211,7 +215,7 @@ function ListExpandDepth(context: { note: FNote, parentComponent: Component }) {
|
||||
<FormDropdownDivider />
|
||||
<ListExpandDepthButton label={t("book_properties.expand_all_levels")} depth="all" checked={currentDepth === "all"} {...context} />
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function ListExpandDepthButton({ label, depth, note, parentComponent, checked }: { label: string, depth: number | "all", note: FNote, parentComponent: Component, checked?: boolean }) {
|
||||
@ -236,5 +240,5 @@ function buildExpandListHandler(depth: number | "all") {
|
||||
|
||||
await attributes.setLabel(noteId, "expanded", newValue);
|
||||
triggerCommand("refreshNoteList", { noteId });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,11 +1,13 @@
|
||||
import "./Book.css";
|
||||
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
|
||||
import { t } from "../../services/i18n";
|
||||
import { ViewTypeOptions } from "../collections/interface";
|
||||
import Alert from "../react/Alert";
|
||||
import { useNoteLabelWithDefault, useTriliumEvent } from "../react/hooks";
|
||||
import RawHtml from "../react/RawHtml";
|
||||
import { TypeWidgetProps } from "./type_widget";
|
||||
import "./Book.css";
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import { ViewTypeOptions } from "../collections/interface";
|
||||
|
||||
const VIEW_TYPES: ViewTypeOptions[] = [ "list", "grid", "presentation" ];
|
||||
|
||||
@ -32,5 +34,5 @@ export default function Book({ note }: TypeWidgetProps) {
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -410,6 +410,7 @@
|
||||
"list-view": "List View",
|
||||
"grid-view": "Grid View",
|
||||
"calendar": "Calendar",
|
||||
"gallery": "Gallery",
|
||||
"table": "Table",
|
||||
"geo-map": "Geo Map",
|
||||
"start-date": "Start Date",
|
||||
|
||||
@ -75,6 +75,27 @@ export default function buildHiddenSubtreeTemplates() {
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "_template_gallery",
|
||||
type: "book",
|
||||
title: t("hidden_subtree_templates.gallery"),
|
||||
icon: "bx bx-images",
|
||||
attributes: [
|
||||
{
|
||||
name: "template",
|
||||
type: "label"
|
||||
},
|
||||
{
|
||||
name: "collection",
|
||||
type: "label"
|
||||
},
|
||||
{
|
||||
name: "viewType",
|
||||
type: "label",
|
||||
value: "gallery"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "_template_calendar",
|
||||
type: "book",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user