feat(gallery): add share functionality and share badge to gallery cards with option to share in context menu

This commit is contained in:
lzinga 2025-12-30 08:49:54 -08:00
parent 1a2a3cbd17
commit cfbb410ee0
2 changed files with 128 additions and 37 deletions

View File

@ -241,6 +241,30 @@
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;

View File

@ -1,5 +1,6 @@
import "./index.css";
import { ToggleInParentResponse } from "@triliumnext/commons";
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
import appContext from "../../../components/app_context";
@ -7,10 +8,12 @@ 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";
@ -23,6 +26,48 @@ import { useFilteredNoteIds } from "../legacy/utils";
const INITIAL_LOAD = 50;
const LOAD_MORE_INCREMENT = 50;
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());
@ -39,27 +84,18 @@ export default function GalleryView({ note, noteIds: unfilteredNoteIds }: ViewMo
const allNotes = noteIds?.map(noteId => froca.notes[noteId]).filter(Boolean) || [];
const notes = allNotes
.filter(childNote => {
const isGallery = childNote.hasLabel('collection') && childNote.getLabelValue('viewType') === 'gallery';
const isImage = childNote.type === 'image' || childNote.type === 'canvas' || childNote.type === 'mermaid' || childNote.type === 'mindMap';
return isGallery || isImage;
})
.filter(childNote => isGalleryNote(childNote) || isVisualType(childNote.type))
.sort((a, b) => {
const aIsGallery = a.hasLabel('collection') && a.getLabelValue('viewType') === 'gallery';
const bIsGallery = b.hasLabel('collection') && b.getLabelValue('viewType') === 'gallery';
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 =>
note.type === 'image' || note.type === 'canvas' || note.type === 'mermaid' || note.type === 'mindMap'
).length;
const galCount = notes.filter(note =>
note.hasLabel('collection') && note.getLabelValue('viewType') === 'gallery'
).length;
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]);
@ -331,15 +367,11 @@ function GalleryToolbar({
}: 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 hasParentGallery = currentNote && currentNote.getParentNotes().some(parent => isGalleryNote(parent));
const handleGoBack = () => {
if (currentNote) {
const parentGallery = currentNote.getParentNotes().find(parent =>
parent.hasLabel('collection') && parent.getLabelValue('viewType') === 'gallery'
);
const parentGallery = currentNote.getParentNotes().find(parent => isGalleryNote(parent));
if (parentGallery) {
appContext.tabManager.getActiveContext()?.setNote(parentGallery.noteId);
@ -445,8 +477,9 @@ interface GalleryCardProps {
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 = note.hasLabel('collection') && note.getLabelValue('viewType') === 'gallery';
const isGallery = isGalleryNote(note);
const childCount = useMemo(() => {
if (!isGallery) {
@ -457,27 +490,14 @@ function GalleryCard({ note, parentNote, isSelected, selectedNoteIds, toggleSele
return childNoteIds.filter(childId => {
const child = froca.notes[childId];
if (!child) return false;
return child.type === 'image' ||
child.type === 'canvas' ||
child.type === 'mermaid' ||
child.type === 'mindMap' ||
(child.hasLabel('collection') && child.getLabelValue('viewType') === 'gallery');
return isVisualType(child.type) || isGalleryNote(child);
}).length;
}, [isGallery, note.children]);
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`);
} else if (note.type === 'mermaid') {
setImageSrc(`api/images/${note.noteId}/mermaid.svg`);
} else if (note.type === 'mindMap') {
setImageSrc(`api/images/${note.noteId}/mindmap.svg`);
}
setImageSrc(getImageSrc(note));
setIsShared(note.isShared());
}, [note, parentNote.noteId]);
const handleRename = async () => {
@ -496,6 +516,17 @@ function GalleryCard({ note, parentNote, isSelected, selectedNoteIds, toggleSele
}
};
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();
@ -541,6 +572,15 @@ function GalleryCard({ note, parentNote, isSelected, selectedNoteIds, toggleSele
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 })
@ -557,6 +597,13 @@ function GalleryCard({ note, parentNote, isSelected, selectedNoteIds, toggleSele
});
};
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" : ""}`}
@ -573,6 +620,16 @@ function GalleryCard({ note, parentNote, isSelected, selectedNoteIds, toggleSele
{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 && (
@ -586,6 +643,16 @@ function GalleryCard({ note, parentNote, isSelected, selectedNoteIds, toggleSele
<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}