From 72a2cfd4aea956f0d3c61ecffcda90c51a249834 Mon Sep 17 00:00:00 2001 From: NriotHrreion Date: Wed, 7 Aug 2024 11:40:53 +0800 Subject: [PATCH] feat: Image thumbnail preview in grid view (#2) --- app/api/fs/thumbnail/route.ts | 44 +++++++++++++++++++ .../explorer/explorer-grid-view-item.tsx | 14 +++++- components/explorer/explorer-item.tsx | 4 +- components/explorer/explorer.tsx | 16 ++++++- components/explorer/navbar.tsx | 13 ++++-- components/viewers/audio-viewer.tsx | 3 +- hooks/useDialog.ts | 2 +- hooks/useExplorer.ts | 10 ++++- hooks/useFile.ts | 2 +- hooks/useFolder.ts | 4 +- lib/utils.ts | 4 ++ 11 files changed, 101 insertions(+), 15 deletions(-) create mode 100644 app/api/fs/thumbnail/route.ts diff --git a/app/api/fs/thumbnail/route.ts b/app/api/fs/thumbnail/route.ts new file mode 100644 index 0000000..bf9e673 --- /dev/null +++ b/app/api/fs/thumbnail/route.ts @@ -0,0 +1,44 @@ +import fs from "node:fs"; + +import { NextRequest, NextResponse } from "next/server"; +import mime from "mime"; + +import { tokenStorageKey } from "@/lib/global"; +import { validateToken } from "@/lib/token"; +import { error } from "@/lib/packet"; +import { getExtname, getFileType } from "@/lib/utils"; +import { streamFile } from "@/lib/stream"; + +export async function GET(req: NextRequest) { + const token = req.cookies.get(tokenStorageKey)?.value; + + if(!token) return error(401); + if(!validateToken(token)) return error(403); + + const { searchParams } = new URL(req.url); + const targetPath = searchParams.get("path") ?? "/"; + + try { + if(!targetPath || !fs.existsSync(targetPath)) return error(404); + + const stat = fs.statSync(targetPath); + + if(!stat.isFile() || getFileType(getExtname(targetPath))?.id !== "image") return error(400); + + const stream = fs.createReadStream(targetPath); + + return new NextResponse(streamFile(stream), { + status: 200, + headers: { + "Content-Disposition": `attachment; filename=thumbnail`, + "Content-Type": mime.getType(targetPath) ?? "application/octet-stream", + "Content-Length": stat.size.toString() + } + }); + } catch (err) { + // eslint-disable-next-line no-console + console.log("[Server: /api/fs/thumbnail] "+ err); + + return error(500); + } +} diff --git a/components/explorer/explorer-grid-view-item.tsx b/components/explorer/explorer-grid-view-item.tsx index d6fb848..d455283 100644 --- a/components/explorer/explorer-grid-view-item.tsx +++ b/components/explorer/explorer-grid-view-item.tsx @@ -8,13 +8,16 @@ import { Tooltip } from "@nextui-org/tooltip"; import { getFileIcon, getFolderIcon } from "./explorer-item"; -import { formatSize, getFileTypeName } from "@/lib/utils"; +import { concatPath, formatSize, getFileType, getFileTypeName } from "@/lib/utils"; +import { useExplorer } from "@/hooks/useExplorer"; interface GridViewItemProps extends ViewItemProps {} const ExplorerGridViewItem: React.FC = ({ extname, size, selected, contextMenu, setSelected, handleSelection, handleOpen, onContextMenu, ...props }) => { + const explorer = useExplorer(); + return (
= ({ { props.type === "folder" ? getFolderIcon(props.name, 34) - : getFileIcon(extname ?? "", 34) + : ( + getFileType(extname ?? "")?.id === "image" + ? thumbnail + : getFileIcon(extname ?? "", 34) + ) }
diff --git a/components/explorer/explorer-item.tsx b/components/explorer/explorer-item.tsx index 7e3d150..9e14ee1 100644 --- a/components/explorer/explorer-item.tsx +++ b/components/explorer/explorer-item.tsx @@ -34,7 +34,7 @@ import ExplorerListViewItem from "./explorer-list-view-item"; import ExplorerGridViewItem from "./explorer-grid-view-item"; import { useExplorer } from "@/hooks/useExplorer"; -import { concatPath, getFileType } from "@/lib/utils"; +import { concatPath, getExtname, getFileType } from "@/lib/utils"; import { getViewer } from "@/lib/viewers"; import { useDialog } from "@/hooks/useDialog"; import { useFile } from "@/hooks/useFile"; @@ -93,7 +93,7 @@ interface ExplorerItemProps extends DirectoryItem { } const ExplorerItem: React.FC = ({ displayingMode, ...props }) => { - const extname = useMemo(() => props.name.split(".").findLast(() => true), [props.name]); + const extname = getExtname(props.name); const [selected, setSelected] = useState(false); diff --git a/components/explorer/explorer.tsx b/components/explorer/explorer.tsx index 26f0dd5..3aa3343 100644 --- a/components/explorer/explorer.tsx +++ b/components/explorer/explorer.tsx @@ -15,6 +15,8 @@ import { useExplorer } from "@/hooks/useExplorer"; import { useDialog } from "@/hooks/useDialog"; import { useForceUpdate } from "@/hooks/useForceUpdate"; import { useEmitter } from "@/hooks/useEmitter"; +import { getExtname, getFileType } from "@/lib/utils"; +import { emitter } from "@/lib/emitter"; interface FolderResponseData extends BaseResponseData { items: DirectoryItem[] @@ -35,13 +37,25 @@ const Explorer: React.FC = () => { .then(({ data }) => { var list: DirectoryItem[] = []; + // Classify the directory items + // And count the amount of image files + var imageCount = 0; data.items.forEach((item) => { if(item.type === "folder") list.push(item); }); data.items.forEach((item) => { - if(item.type === "file") list.push(item); + if(item.type === "file") { + list.push(item); + if(getFileType(getExtname(item.name))?.id === "image") { + imageCount++; + } + } }); + const defaultDisplayingMode = imageCount >= 5 ? "grid" : "list"; + explorer.displayingMode = defaultDisplayingMode; + emitter.emit("displaying-mode-change", defaultDisplayingMode); + setItems(list); }) .catch((err: AxiosError) => { diff --git a/components/explorer/navbar.tsx b/components/explorer/navbar.tsx index bbcfea0..73a5456 100644 --- a/components/explorer/navbar.tsx +++ b/components/explorer/navbar.tsx @@ -1,5 +1,7 @@ "use client"; +import type { DisplayingMode } from "@/types"; + import React, { useEffect, useCallback, useState, useMemo } from "react"; import { Breadcrumbs, BreadcrumbItem } from "@nextui-org/breadcrumbs"; import { Input } from "@nextui-org/input"; @@ -15,8 +17,9 @@ import DiskItem from "./disk-item"; import { parseStringPath, useExplorer } from "@/hooks/useExplorer"; import { useFerrum } from "@/hooks/useFerrum"; -import { concatPath, isValidPath } from "@/lib/utils"; +import { concatPath, getExtname, isValidPath } from "@/lib/utils"; import { emitter } from "@/lib/emitter"; +import { useEmitter } from "@/hooks/useEmitter"; const Navbar: React.FC = () => { const ferrum = useFerrum(); @@ -77,9 +80,13 @@ const Navbar: React.FC = () => { useEffect(() => { explorer.displayingMode = displayingMode; - emitter.emit("displaying-mode-change"); + emitter.emit("displaying-mode-change", displayingMode); }, [displayingMode]); + useEmitter([ + ["displaying-mode-change", (current: DisplayingMode) => setDisplayingMode(current)] + ]); + return ( <>
@@ -191,7 +198,7 @@ const Navbar: React.FC = () => { - {getFileIcon(explorer.currentViewing.split(".").findLast(() => true) ?? "")} + {getFileIcon(getExtname(explorer.currentViewing))} {explorer.currentViewing} ) diff --git a/components/viewers/audio-viewer.tsx b/components/viewers/audio-viewer.tsx index 9b9f4e6..34a05d9 100644 --- a/components/viewers/audio-viewer.tsx +++ b/components/viewers/audio-viewer.tsx @@ -14,6 +14,7 @@ import Viewer, { ViewerProps } from "."; import PlayIcon from "@/styles/icons/play.svg"; import PauseIcon from "@/styles/icons/pause.svg"; import StopIcon from "@/styles/icons/stop.svg"; +import { getExtname } from "@/lib/utils"; interface Lyric { time: number @@ -119,7 +120,7 @@ export default class AudioViewer extends Viewer : (
- {this.props.fileName.split(".").findLast(() => true)?.toUpperCase()} + {getExtname(this.props.fileName).toUpperCase()}
) } diff --git a/hooks/useDialog.ts b/hooks/useDialog.ts index 7734bbe..e53031f 100644 --- a/hooks/useDialog.ts +++ b/hooks/useDialog.ts @@ -14,6 +14,6 @@ export const useDialog = create((set) => ({ type: null, data: null, isOpened: false, - open: (type: DialogType, data?: any) => set({ type, data, isOpened: true }), + open: (type, data?) => set({ type, data, isOpened: true }), close: () => set({ type: null, data: null, isOpened: false }), })); diff --git a/hooks/useExplorer.ts b/hooks/useExplorer.ts index 8e46456..6b806fb 100644 --- a/hooks/useExplorer.ts +++ b/hooks/useExplorer.ts @@ -5,6 +5,7 @@ import { to } from "preps"; import { storage } from "@/lib/storage"; import { diskStorageKey } from "@/lib/global"; +import { emitter } from "@/lib/emitter"; interface ExplorerStore { path: string[] @@ -15,6 +16,7 @@ interface ExplorerStore { setPath: (path: string[]) => void setDisk: (disk: string) => void setCurrentViewing: (file: string) => void + setDisplayingMode: (displayingMode: DisplayingMode) => void clearCurrentViewing: () => void stringifyPath: () => string enterPath: (target: string) => void @@ -46,14 +48,18 @@ export const useExplorer = create((set, get) => ({ displayingMode: "list", setPath: (path) => set({ path }), - setDisk: (disk: string) => { + setDisk: (disk) => { set({ disk }); storage.setItem(diskStorageKey, disk); }, setCurrentViewing: (file) => set({ currentViewing: file }), + setDisplayingMode: (displayingMode) => { + set({ displayingMode }); + emitter.emit("displaying-mode-change", displayingMode); + }, clearCurrentViewing: () => set({ currentViewing: null }), stringifyPath: () => stringifyPath(get().path), - enterPath: (target: string) => { + enterPath: (target) => { set({ path: [...get().path, target] }); }, backToRoot: () => { diff --git a/hooks/useFile.ts b/hooks/useFile.ts index e03c09d..bc8c5c6 100644 --- a/hooks/useFile.ts +++ b/hooks/useFile.ts @@ -14,7 +14,7 @@ export function useFile(path: string): FileOperations { const fullPath = explorer.disk + path; return { - rename: async (newName: string) => { + rename: async (newName) => { if(/[\\\/:*?"<>|]/.test(newName)) { toast.warn(`文件名称中不能包含下列任何字符 \\ / : * ? " < > |`); diff --git a/hooks/useFolder.ts b/hooks/useFolder.ts index d7470ea..8198571 100644 --- a/hooks/useFolder.ts +++ b/hooks/useFolder.ts @@ -20,7 +20,7 @@ export function useFolder(fullPath: string): FolderOperations { const explorer = useExplorer(); return { - rename: async (newName: string) => { + rename: async (newName) => { if(/[\\\/:*?"<>|]/.test(newName)) { toast.warn(`文件夹名称中不能包含下列任何字符 \\ / : * ? " < > |`); @@ -107,7 +107,7 @@ export function useFolder(fullPath: string): FolderOperations { } }); }, - create: async (name: string, type: "folder" | "file") => { + create: async (name, type) => { const typeName = type === "folder" ? "文件夹" : "文件"; if(/[\\\/:*?"<>|]/.test(name)) { diff --git a/lib/utils.ts b/lib/utils.ts index 9834080..a40dc69 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -52,6 +52,10 @@ export function formatSize(bytes: number, fixed: number = 2): string { return size.value + getBytesType(size.type); } +export function getExtname(fileName: string): string { + return fileName.split(".").findLast(() => true) ?? ""; +} + export function getFileType(extname: string): FileType | null { extname = extname.toLowerCase();