diff --git a/public/locales/de-DE/application.json b/public/locales/de-DE/application.json index 0822837d..e9a13bec 100644 --- a/public/locales/de-DE/application.json +++ b/public/locales/de-DE/application.json @@ -315,6 +315,9 @@ "moreActions": "Weitere Aktionen", "refresh": "Aktualisieren", "createArchive": "Archiv erstellen", + "resetThumbnail": "Miniaturansicht zurücksetzen", + "resetThumbnailRequested": "Zurücksetzen der Miniaturansicht angefordert.", + "noFileCanResetThumbnail": "Keine Dateien zum Zurücksetzen der Miniaturansicht verfügbar.", "newFolder": "Neuen Ordner erstellen", "newFile": "Neue Datei erstellen", "showFullPath": "Pfad anzeigen", diff --git a/public/locales/en-US/application.json b/public/locales/en-US/application.json index b425af7b..1278c74a 100644 --- a/public/locales/en-US/application.json +++ b/public/locales/en-US/application.json @@ -281,6 +281,9 @@ "moreActions": "More actions", "refresh": "Refresh", "createArchive": "Create archive file", + "resetThumbnail": "Reset thumbnail", + "resetThumbnailRequested": "Thumbnail reset requested.", + "noFileCanResetThumbnail": "No files available for thumbnail reset.", "newFolder": "New folder", "newFile": "New file", "showFullPath": "Show full path", diff --git a/public/locales/es-ES/application.json b/public/locales/es-ES/application.json index e62fa4f8..305f1b36 100644 --- a/public/locales/es-ES/application.json +++ b/public/locales/es-ES/application.json @@ -315,6 +315,9 @@ "moreActions": "Más acciones", "refresh": "Actualizar", "createArchive": "Crear archivo comprimido", + "resetThumbnail": "Restablecer miniatura", + "resetThumbnailRequested": "Se solicitó restablecer la miniatura.", + "noFileCanResetThumbnail": "No hay archivos disponibles para restablecer la miniatura.", "newFolder": "Nueva carpeta", "newFile": "Nuevo archivo", "showFullPath": "Mostrar ruta completa", diff --git a/public/locales/fr-FR/application.json b/public/locales/fr-FR/application.json index f51f7b69..a6988b6f 100644 --- a/public/locales/fr-FR/application.json +++ b/public/locales/fr-FR/application.json @@ -19,6 +19,9 @@ "password": "Mot de passe", "captcha": "CAPTCHA", "captchaError": "Impossible de charger le CAPTCHA : {{message}}", + "resetThumbnail": "Réinitialiser la miniature", + "resetThumbnailRequested": "Réinitialisation de la miniature demandée.", + "noFileCanResetThumbnail": "Aucun fichier pouvant réinitialiser la miniature.", "signIn": "Se connecter", "signUp": "S'inscrire", "signUpAccount": "S'inscrire", diff --git a/public/locales/it-IT/application.json b/public/locales/it-IT/application.json index 2e18a479..e68a6741 100644 --- a/public/locales/it-IT/application.json +++ b/public/locales/it-IT/application.json @@ -53,6 +53,9 @@ "loggedOut": "Sei stato disconnesso", "clickToRefresh": "Clicca per aggiornare il CAPTCHA" }, + "resetThumbnail": "Reimposta miniatura", + "resetThumbnailRequested": "Reimpostazione della miniatura richiesta.", + "noFileCanResetThumbnail": "Nessun file può reimpostare la miniatura.", "navbar": { "notBefore": "Non prima di", "notAfter": "Non dopo", diff --git a/public/locales/ja-JP/application.json b/public/locales/ja-JP/application.json index 272cd44b..e3a2da5a 100644 --- a/public/locales/ja-JP/application.json +++ b/public/locales/ja-JP/application.json @@ -53,6 +53,9 @@ "loggedOut": "ログアウトしました", "clickToRefresh": "CAPTCHAを再読み込み" }, + "resetThumbnail": "サムネイルをリセット", + "resetThumbnailRequested": "サムネイルのリセットをリクエストしました。", + "noFileCanResetThumbnail": "サムネイルをリセットできるファイルがありません。", "navbar": { "notBefore": "~より前", "notAfter": "~より後", diff --git a/public/locales/ko-KR/application.json b/public/locales/ko-KR/application.json index d9e2678e..01886d4c 100644 --- a/public/locales/ko-KR/application.json +++ b/public/locales/ko-KR/application.json @@ -87,6 +87,9 @@ "photos": "사진", "music": "음악", "documents": "문서", + "resetThumbnail": "썸네일 재설정", + "resetThumbnailRequested": "썸네일 재설정이 요청되었습니다.", + "noFileCanResetThumbnail": "썸네일을 재설정할 수 있는 파일이 없습니다.", "addATag": "태그 추가...", "addTagDialog": { "selectFolder": "폴더 선택", diff --git a/public/locales/pt-BR/application.json b/public/locales/pt-BR/application.json index 767b0b0c..311dc344 100644 --- a/public/locales/pt-BR/application.json +++ b/public/locales/pt-BR/application.json @@ -74,6 +74,9 @@ "recentlyViewed": "Visualizados recentemente", "searchFiles": "Buscar arquivos...", "showMore": "Mais", + "resetThumbnail": "Redefinir miniatura", + "resetThumbnailRequested": "Redefinição de miniatura solicitada.", + "noFileCanResetThumbnail": "Nenhum arquivo pode redefinir a miniatura.", "myFiles": "Meus arquivos", "hisFiles": "Arquivos dele/dela", "trash": "Lixeira", diff --git a/public/locales/ru-RU/application.json b/public/locales/ru-RU/application.json index 04355c2b..9ece769d 100644 --- a/public/locales/ru-RU/application.json +++ b/public/locales/ru-RU/application.json @@ -67,6 +67,9 @@ "notNameOpOr": "Должны содержаться все ключевые слова", "caseFolding": "Игнорировать регистр", "keywords": "Ключевые слова", + "resetThumbnail": "Сбросить миниатюру", + "resetThumbnailRequested": "Запрошен сброс миниатюры.", + "noFileCanResetThumbnail": "Нет файлов для сброса миниатюры.", "fileNameKeywordsHelp": "Нажмите Enter для добавления ключевого слова", "advancedSearch": "Расширенный поиск", "searchFilesTitle": "Поиск файлов", diff --git a/public/locales/zh-CN/application.json b/public/locales/zh-CN/application.json index 709b7b0b..42c9451f 100644 --- a/public/locales/zh-CN/application.json +++ b/public/locales/zh-CN/application.json @@ -281,6 +281,9 @@ "moreActions": "更多操作", "refresh": "刷新", "createArchive": "创建压缩文件", + "resetThumbnail": "重置缩略图", + "resetThumbnailRequested": "已请求重置缩略图。", + "noFileCanResetThumbnail": "没有可重置缩略图的文件。", "newFolder": "创建文件夹", "newFile": "创建文件", "showFullPath": "显示路径", diff --git a/public/locales/zh-TW/application.json b/public/locales/zh-TW/application.json index e43d8119..328fe358 100644 --- a/public/locales/zh-TW/application.json +++ b/public/locales/zh-TW/application.json @@ -57,6 +57,9 @@ "notBefore": "不早於", "notAfter": "不晚於", "minimum": "最小", + "resetThumbnail": "重設縮圖", + "resetThumbnailRequested": "已請求重設縮圖。", + "noFileCanResetThumbnail": "沒有可重設縮圖的檔案。", "maximum": "最大", "fileSize": "檔案大小", "searchBase": "搜尋路徑", diff --git a/src/api/api.ts b/src/api/api.ts index cd4bbc48..953802d5 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -292,6 +292,24 @@ export function getFileThumb(path: string, contextHint?: string): ThunkResponse< }; } +// Thin wrapper to query supported thumbnail extensions from backend +export function getThumbExts(): ThunkResponse<{ thumb_exts?: string[] }> { + return async (dispatch, _getState) => { + return await dispatch( + send<{ thumb_exts?: string[] }>( + "/site/config/thumb", + { + method: "GET", + }, + { + ...defaultOpts, + noCredential: true, + }, + ), + ); + }; +} + export function getUserInfo(uid: string): ThunkResponse { return async (dispatch, _getState) => { return await dispatch( diff --git a/src/component/FileManager/ContextMenu/ContextMenu.tsx b/src/component/FileManager/ContextMenu/ContextMenu.tsx index d7e74c9b..7776f025 100644 --- a/src/component/FileManager/ContextMenu/ContextMenu.tsx +++ b/src/component/FileManager/ContextMenu/ContextMenu.tsx @@ -1,5 +1,5 @@ import { Box, Divider, ListItemIcon, ListItemText, Menu, MenuItem, styled, Typography, useTheme } from "@mui/material"; -import { useCallback, useMemo } from "react"; +import { useCallback, useEffect, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { closeContextMenu } from "../../../redux/fileManagerSlice.ts"; import { CreateNewDialogType } from "../../../redux/globalStateSlice.ts"; @@ -22,6 +22,7 @@ import { } from "../../../redux/thunks/file.ts"; import { refreshFileList, uploadClicked, uploadFromClipboard } from "../../../redux/thunks/filemanager.ts"; import { openViewers } from "../../../redux/thunks/viewer.ts"; +import { primeThumbExtsCache } from "../../../redux/thunks/thumb.ts"; import AppFolder from "../../Icons/AppFolder.tsx"; import ArchiveArrow from "../../Icons/ArchiveArrow.tsx"; import ArrowSync from "../../Icons/ArrowSync.tsx"; @@ -107,6 +108,13 @@ const ContextMenu = ({ fmIndex = 0 }: ContextMenuProps) => { dispatch(closeContextMenu({ index: fmIndex, value: undefined })); }, [dispatch]); + // Ensure supported thumbnail extensions are primed when menu opens + useEffect(() => { + if (contextMenuOpen) { + dispatch(primeThumbExtsCache()); + } + }, [contextMenuOpen, dispatch]); + const showOpenWithCascading = displayOpt.showOpenWithCascading && displayOpt.showOpenWithCascading(); const showOpenWith = displayOpt.showOpenWith && displayOpt.showOpenWith(); let part1 = diff --git a/src/component/FileManager/ContextMenu/MoreMenuItems.tsx b/src/component/FileManager/ContextMenu/MoreMenuItems.tsx index 448ed8d3..fbaf6f57 100644 --- a/src/component/FileManager/ContextMenu/MoreMenuItems.tsx +++ b/src/component/FileManager/ContextMenu/MoreMenuItems.tsx @@ -10,11 +10,13 @@ import { } from "../../../redux/globalStateSlice.ts"; import { useAppDispatch } from "../../../redux/hooks.ts"; import Archive from "../../Icons/Archive.tsx"; +import RectangleLandscapeSync from "../../Icons/RectangleLandscapeSync.tsx"; import BranchForkLink from "../../Icons/BranchForkLink.tsx"; import HistoryOutlined from "../../Icons/HistoryOutlined.tsx"; import LinkSetting from "../../Icons/LinkSetting.tsx"; import { CascadingContext, CascadingMenuItem } from "./CascadingMenu.tsx"; import { SubMenuItemsProps } from "./OrganizeMenuItems.tsx"; +import { resetThumbnails } from "../../../redux/thunks/file.ts"; const MoreMenuItems = ({ displayOpt, targets }: SubMenuItemsProps) => { const { rootPopupState } = useContext(CascadingContext); @@ -105,6 +107,14 @@ const MoreMenuItems = ({ displayOpt, targets }: SubMenuItemsProps) => { {t("application:fileManager.createArchive")} )} + {displayOpt.showResetThumb && ( + dispatch(resetThumbnails(targets)))}> + + + + {t("application:fileManager.resetThumbnail")} + + )} ); }; diff --git a/src/component/FileManager/ContextMenu/useActionDisplayOpt.ts b/src/component/FileManager/ContextMenu/useActionDisplayOpt.ts index 9e29a029..e7bf63fb 100644 --- a/src/component/FileManager/ContextMenu/useActionDisplayOpt.ts +++ b/src/component/FileManager/ContextMenu/useActionDisplayOpt.ts @@ -1,5 +1,6 @@ import { useMemo } from "react"; import { FileResponse, FileType, Metadata, NavigatorCapability } from "../../../api/explorer.ts"; +import { getCachedThumbExts } from "../../../redux/thunks/thumb.ts"; import { GroupPermission } from "../../../api/user.ts"; import { defaultPath } from "../../../hooks/useNavigation.tsx"; import { ContextMenuTypes } from "../../../redux/fileManagerSlice.ts"; @@ -77,6 +78,7 @@ export interface DisplayOption { showDirectLinkManagement?: boolean; showManageShares?: boolean; showCreateArchive?: boolean; + showResetThumb?: boolean; andCapability?: Boolset; orCapability?: Boolset; @@ -291,11 +293,23 @@ export const getActionOpt = ( display.orCapability && display.orCapability.enabled(NavigatorCapability.download_file); + // Reset thumbnail is available when at least one file is selected and + // current capability allows generating thumbnails + // Show only when at least one selected file has a supported extension, + // based on cached supported thumbnail extensions. + const cache = getCachedThumbExts(); + const anySupported = + cache instanceof Set + ? targets.some((f) => f.type == FileType.file && cache.has((fileExtension(f.name) || "").toLowerCase())) + : false; + display.showResetThumb = display.hasFile && anySupported; + display.showMore = display.showVersionControl || display.showManageShares || display.showCreateArchive || - display.showDirectLinkManagement; + display.showDirectLinkManagement || + display.showResetThumb; return display; }; diff --git a/src/component/FileManager/Explorer/GalleryView/GalleryImage.tsx b/src/component/FileManager/Explorer/GalleryView/GalleryImage.tsx index 7a87a4f7..c20ca124 100644 --- a/src/component/FileManager/Explorer/GalleryView/GalleryImage.tsx +++ b/src/component/FileManager/Explorer/GalleryView/GalleryImage.tsx @@ -93,8 +93,11 @@ const GalleryImage = memo((props: FileBlockProps) => { return; } + // Reset to loading state before reloading thumb (e.g., after reset) + setImageLoading(true); + setThumbSrc(undefined); tryLoadThumbSrc(); - }, [inView]); + }, [inView, file, file.metadata?.[Metadata.thumbDisabled]]); const onIconClick = useCallback( (e: React.MouseEvent) => { diff --git a/src/component/FileManager/Explorer/GridView/GridFile.tsx b/src/component/FileManager/Explorer/GridView/GridFile.tsx index 8679b903..57e7e5b9 100644 --- a/src/component/FileManager/Explorer/GridView/GridFile.tsx +++ b/src/component/FileManager/Explorer/GridView/GridFile.tsx @@ -328,8 +328,11 @@ const GridFile = memo((props: FileBlockProps) => { return; } + // Reset to loading state before reloading thumb (e.g., after reset) + setImageLoading(true); + setThumbSrc(undefined); tryLoadThumbSrc(); - }, [inView]); + }, [inView, file, file.metadata?.[Metadata.thumbDisabled]]); const hoverProps = bindDelayedHover(popupState, 800); const { open: thumbPopoverOpen, ...rest } = bindPopover(popupState); diff --git a/src/component/FileManager/FileManager.tsx b/src/component/FileManager/FileManager.tsx index f0d91f9e..995e0cf1 100644 --- a/src/component/FileManager/FileManager.tsx +++ b/src/component/FileManager/FileManager.tsx @@ -6,6 +6,7 @@ import { clearSelected } from "../../redux/fileManagerSlice.ts"; import { resetDialogs } from "../../redux/globalStateSlice.ts"; import { useAppDispatch } from "../../redux/hooks.ts"; import { resetFm, selectAll, shortCutDelete } from "../../redux/thunks/filemanager.ts"; +import { primeThumbExtsCache } from "../../redux/thunks/thumb.ts"; import ImageViewer from "../Viewers/ImageViewer/ImageViewer.tsx"; import Explorer from "./Explorer/Explorer.tsx"; import { FmIndexContext } from "./FmIndexContext.tsx"; @@ -37,6 +38,8 @@ export const FileManager = ({ index = 0, initialPath, skipRender }: FileManagerP useEffect(() => { if (index == FileManagerIndex.main) { dispatch(resetDialogs()); + // Prime supported thumbnail extension cache once per page + dispatch(primeThumbExtsCache()); return () => { dispatch(resetFm(index)); }; diff --git a/src/redux/thunks/file.ts b/src/redux/thunks/file.ts index 7e204a77..43ce1700 100644 --- a/src/redux/thunks/file.ts +++ b/src/redux/thunks/file.ts @@ -15,6 +15,7 @@ import { sendUnlockFiles, setCurrentVersion, } from "../../api/api.ts"; +import { getCachedThumbExts } from "./thumb.ts"; import { ConflictDetail, DirectLink, @@ -39,7 +40,7 @@ import { loadingDebounceMs } from "../../constants"; import { defaultPath } from "../../hooks/useNavigation.tsx"; import SessionManager, { UserSettings } from "../../session"; import { addRecentUsedColor, addUsedTags } from "../../session/utils.ts"; -import { getFileLinkedUri } from "../../util"; +import { fileExtension, getFileLinkedUri } from "../../util"; import Boolset from "../../util/boolset.ts"; import { canCopyMoveTo } from "../../util/permission.ts"; import CrUri, { Filesystem } from "../../util/uri.ts"; @@ -1154,6 +1155,64 @@ export function batchGetDirectLinks(index: number, files: FileResponse[]): AppTh }; } +export function resetThumbnails(files: FileResponse[]): AppThunk { + return async (dispatch, getState) => { + const cache = getCachedThumbExts(); + const uris = files + .filter((f) => f.type == FileType.file) + .filter((f) => + cache === undefined || cache === null ? true : cache.has((fileExtension(f.name) || "").toLowerCase()), + ) + .map((f) => getFileLinkedUri(f)); + + if (uris.length === 0) { + enqueueSnackbar({ + message: i18next.t("application:fileManager.noFileCanResetThumbnail"), + preventDuplicate: true, + variant: "warning", + action: DefaultCloseAction, + }); + return; + } + + try { + // Re-enable thumbnails by removing the disable mark, and update local metadata/cache. + const targetFiles = files + .filter((f) => f.type == FileType.file) + .filter((f) => + cache === undefined || cache === null ? true : cache.has((fileExtension(f.name) || "").toLowerCase()), + ); + + await dispatch( + patchFileMetadata(FileManagerIndex.main, targetFiles, [ + { + key: Metadata.thumbDisabled, + remove: true, + }, + ]), + ); + + // 预取:立即为所选文件请求缩略图(不依赖列表刷新或滚动触发) + const fm = getState().fileManager[FileManagerIndex.main]; + const toPrefetch = targetFiles + .map((f) => fm.list?.files.find((ff) => ff.path === f.path) || f) + .filter((f): f is FileResponse => !!f); + // 并发触发 GET /file/thumb + await Promise.allSettled(toPrefetch.map((f) => dispatch(loadFileThumb(FileManagerIndex.main, f)))); + + // 成功信息 + enqueueSnackbar({ + message: i18next.t("application:fileManager.resetThumbnailRequested"), + variant: "success", + action: DefaultCloseAction, + }); + // 不再刷新文件列表;组件会基于metadata变化自动重新请求所选文件的缩略图 + } catch (_e) { + // Error snackbar is handled in send() + } + }; +} + // Single file symbolic links might be invalid if original file is renamed by its owner, // we need to refresh the symbolic links by getting the latest file list export function refreshSingleFileSymbolicLinks(file: FileResponse): AppThunk> { diff --git a/src/redux/thunks/thumb.ts b/src/redux/thunks/thumb.ts new file mode 100644 index 00000000..0fb32780 --- /dev/null +++ b/src/redux/thunks/thumb.ts @@ -0,0 +1,50 @@ +import { AppThunk } from "../store.ts"; +import { getThumbExts } from "../../api/api.ts"; + +// --- Cached supported thumbnail extensions helpers --- +let __thumbExtsCache: Set | null | undefined = undefined; // undefined: not fetched, null: unknown/fallback + +// Get supported thumbnail file extensions by reading enabled generators' settings +export function getSupportedThumbExts(): AppThunk> { + return async (dispatch, _getState) => { + // Read from new site config endpoint; fallback to empty list. + try { + const remote = await dispatch(getThumbExts()); + const list = remote?.thumb_exts ?? []; + return Array.isArray(list) ? list : []; + } catch (_e) { + // Treat as unsupported when not available + return []; + } + }; +} + +// Prime cache once per page. Safe to call multiple times. +export function primeThumbExtsCache(): AppThunk> { + return async (dispatch, _getState) => { + if (__thumbExtsCache !== undefined) return; + try { + const exts = await dispatch(getSupportedThumbExts()); + __thumbExtsCache = new Set(exts.map((e) => e.toLowerCase())); + } catch (_e) { + // Mark as unknown to fall back to legacy behavior + __thumbExtsCache = null; + } + }; +} + +export function getCachedThumbExts(): Set | null | undefined { + return __thumbExtsCache; +} + +// Check if a file name is likely supported based on cached exts +// Returns undefined if cache is not ready (treat as supported by caller). +export function isThumbExtSupportedSync(fileName: string): boolean | undefined { + const cache = __thumbExtsCache; + if (cache === undefined) return undefined; + if (cache === null) return true; // unknown => allow + const idx = fileName.lastIndexOf("."); + const ext = idx >= 0 ? fileName.substring(idx + 1).toLowerCase() : ""; + if (!ext) return false; + return cache.has(ext); +}