feat: impl image preview dialog

This commit is contained in:
2025-09-20 19:55:20 +00:00
parent ddd2afb879
commit 367e248062
7 changed files with 345 additions and 101 deletions

View File

@@ -12,6 +12,15 @@ export const generateUploadUrl = authenticatedMutation({
}, },
}) })
export const generateFileUrl = authenticatedQuery({
args: {
storageId: v.id("_storage"),
},
handler: async (ctx, { storageId }) => {
return await ctx.storage.getUrl(storageId)
},
})
export const fetchFiles = authenticatedQuery({ export const fetchFiles = authenticatedQuery({
args: { args: {
directoryId: v.optional(v.id("directories")), directoryId: v.optional(v.id("directories")),

View File

@@ -0,0 +1,195 @@
import { api } from "@fileone/convex/_generated/api"
import type { Doc } from "@fileone/convex/_generated/dataModel"
import { DialogTitle } from "@radix-ui/react-dialog"
import { useQuery as useConvexQuery } from "convex/react"
import { atom, useAtom, useAtomValue, useSetAtom } from "jotai"
import {
Maximize2Icon,
Minimize2Icon,
XIcon,
ZoomInIcon,
ZoomOutIcon,
} from "lucide-react"
import { useEffect, useRef } from "react"
import { Button } from "./ui/button"
import {
Dialog,
DialogClose,
DialogContent,
DialogHeader,
DialogOverlay,
} from "./ui/dialog"
import { LoadingSpinner } from "./ui/loading-spinner"
const zoomLevelAtom = atom(
1,
(get, set, update: number | ((current: number) => number)) => {
const current = get(zoomLevelAtom)
console.log("current", current)
const newValue = typeof update === "function" ? update(current) : update
if (newValue >= 0.1) {
set(zoomLevelAtom, newValue)
}
},
)
export function ImagePreviewDialog({
file,
onClose,
}: {
file: Doc<"files">
onClose: () => void
}) {
const fileUrl = useConvexQuery(api.files.generateFileUrl, {
storageId: file.storageId,
})
const setZoomLevel = useSetAtom(zoomLevelAtom)
useEffect(
() => () => {
setZoomLevel(1)
},
[setZoomLevel],
)
return (
<Dialog
open
onOpenChange={(open) => {
if (!open) {
onClose()
}
}}
>
<DialogOverlay className="flex items-center justify-center">
{!fileUrl ? (
<LoadingSpinner className="text-neutral-200 size-10" />
) : null}
</DialogOverlay>
{fileUrl ? <PreviewContent fileUrl={fileUrl} file={file} /> : null}
</Dialog>
)
}
function PreviewContent({
fileUrl,
file,
}: {
fileUrl: string
file: Doc<"files">
}) {
return (
<DialogContent
showCloseButton={false}
className="p-0 lg:min-w-1/3 gap-0"
>
<DialogHeader className="border-b border-b-border p-4 flex flex-row items-center justify-between">
<DialogTitle>{file.name}</DialogTitle>
<div className="flex flex-row items-center space-x-2">
<Toolbar />
<Button variant="ghost" size="icon" asChild>
<DialogClose>
<XIcon />
<span className="sr-only">Close</span>
</DialogClose>
</Button>
</div>
</DialogHeader>
<div className="w-full h-full flex items-center justify-center max-h-[calc(100vh-10rem)] overflow-auto">
<ImagePreview fileUrl={fileUrl} file={file} />
</div>
</DialogContent>
)
}
function Toolbar() {
const setZoomLevel = useSetAtom(zoomLevelAtom)
const zoomInterval = useRef<ReturnType<typeof setInterval> | null>(null)
useEffect(
() => () => {
if (zoomInterval.current) {
clearInterval(zoomInterval.current)
console.log("clearInterval")
zoomInterval.current = null
}
},
[],
)
function startZooming(delta: number) {
setZoomLevel((zoom) => zoom + delta)
zoomInterval.current = setInterval(() => {
setZoomLevel((zoom) => zoom + delta)
}, 100)
}
function stopZooming() {
if (zoomInterval.current) {
clearInterval(zoomInterval.current)
zoomInterval.current = null
}
}
return (
<div className="flex flex-row items-center space-x-2 border-r border-r-border pr-2">
<ResetZoomButton />
<Button
variant="ghost"
onMouseDown={() => {
startZooming(0.1)
}}
onMouseLeave={stopZooming}
onMouseUp={stopZooming}
>
<ZoomInIcon />
</Button>
<Button
variant="ghost"
onMouseDown={() => {
startZooming(-0.1)
}}
onMouseLeave={stopZooming}
onMouseUp={stopZooming}
>
<ZoomOutIcon />
</Button>
</div>
)
}
function ResetZoomButton() {
const [zoomLevel, setZoomLevel] = useAtom(zoomLevelAtom)
if (zoomLevel === 1) {
return null
}
return (
<Button
variant="ghost"
onClick={() => {
setZoomLevel(1)
}}
>
{zoomLevel > 1 ? <Minimize2Icon /> : <Maximize2Icon />}
</Button>
)
}
function ImagePreview({
fileUrl,
file,
}: {
fileUrl: string
file: Doc<"files">
}) {
const zoomLevel = useAtomValue(zoomLevelAtom)
return (
<img
src={fileUrl}
alt={file.name}
style={{ transform: `scale(${zoomLevel})` }}
/>
)
}

View File

@@ -1,141 +1,144 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog" import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react" import { XIcon } from "lucide-react"
import type * as React from "react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
function Dialog({ function Dialog({
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) { }: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} /> return <DialogPrimitive.Root data-slot="dialog" {...props} />
} }
function DialogTrigger({ function DialogTrigger({
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) { }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} /> return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
} }
function DialogPortal({ function DialogPortal({
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) { }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} /> return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
} }
function DialogClose({ function DialogClose({
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) { }: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} /> return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
} }
function DialogOverlay({ function DialogOverlay({
className, className,
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) { }: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return ( return (
<DialogPrimitive.Overlay <DialogPrimitive.Overlay
data-slot="dialog-overlay" data-slot="dialog-overlay"
className={cn( className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50", "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50 backdrop-blur-xs",
className className,
)} )}
{...props} {...props}
/> />
) )
} }
function DialogContent({ function DialogContent({
className, className,
children, children,
showCloseButton = true, showCloseButton = true,
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & { }: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean showCloseButton?: boolean
}) { }) {
return ( return (
<DialogPortal data-slot="dialog-portal"> <DialogPortal data-slot="dialog-portal">
<DialogOverlay /> <DialogOverlay />
<DialogPrimitive.Content <DialogPrimitive.Content
data-slot="dialog-content" data-slot="dialog-content"
className={cn( className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg", "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className className,
)} )}
{...props} {...props}
> >
{children} {children}
{showCloseButton && ( {showCloseButton && (
<DialogPrimitive.Close <DialogPrimitive.Close
data-slot="dialog-close" data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4" className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
> >
<XIcon /> <XIcon />
<span className="sr-only">Close</span> <span className="sr-only">Close</span>
</DialogPrimitive.Close> </DialogPrimitive.Close>
)} )}
</DialogPrimitive.Content> </DialogPrimitive.Content>
</DialogPortal> </DialogPortal>
) )
} }
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="dialog-header" data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)} className={cn(
{...props} "flex flex-col gap-2 text-center sm:text-left",
/> className,
) )}
{...props}
/>
)
} }
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="dialog-footer" data-slot="dialog-footer"
className={cn( className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className className,
)} )}
{...props} {...props}
/> />
) )
} }
function DialogTitle({ function DialogTitle({
className, className,
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) { }: React.ComponentProps<typeof DialogPrimitive.Title>) {
return ( return (
<DialogPrimitive.Title <DialogPrimitive.Title
data-slot="dialog-title" data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)} className={cn("text-lg leading-none font-semibold", className)}
{...props} {...props}
/> />
) )
} }
function DialogDescription({ function DialogDescription({
className, className,
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) { }: React.ComponentProps<typeof DialogPrimitive.Description>) {
return ( return (
<DialogPrimitive.Description <DialogPrimitive.Description
data-slot="dialog-description" data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)} className={cn("text-muted-foreground text-sm", className)}
{...props} {...props}
/> />
) )
} }
export { export {
Dialog, Dialog,
DialogClose, DialogClose,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogOverlay, DialogOverlay,
DialogPortal, DialogPortal,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} }

View File

@@ -3,6 +3,7 @@ import type { DirectoryItem } from "@fileone/convex/model/directories"
import { createContext } from "react" import { createContext } from "react"
type DirectoryPageContextType = { type DirectoryPageContextType = {
rootDirectory: Doc<"directories">
directory: Doc<"directories"> directory: Doc<"directories">
directoryContent: DirectoryItem[] directoryContent: DirectoryItem[]
} }

View File

@@ -41,6 +41,7 @@ import {
contextMenuTargeItemAtom, contextMenuTargeItemAtom,
itemBeingRenamedAtom, itemBeingRenamedAtom,
newItemKindAtom, newItemKindAtom,
openedFileAtom,
optimisticDeletedItemsAtom, optimisticDeletedItemsAtom,
} from "./state" } from "./state"
@@ -83,7 +84,7 @@ const columns: ColumnDef<DirectoryItem>[] = [
cell: ({ row }) => { cell: ({ row }) => {
switch (row.original.kind) { switch (row.original.kind) {
case "file": case "file":
return <FileNameCell initialName={row.original.doc.name} /> return <FileNameCell file={row.original.doc} />
case "directory": case "directory":
return <DirectoryNameCell directory={row.original.doc} /> return <DirectoryNameCell directory={row.original.doc} />
} }
@@ -413,11 +414,20 @@ function DirectoryNameCell({ directory }: { directory: Doc<"directories"> }) {
) )
} }
function FileNameCell({ initialName }: { initialName: string }) { function FileNameCell({ file }: { file: Doc<"files"> }) {
const setOpenedFile = useSetAtom(openedFileAtom)
return ( return (
<div className="flex w-full items-center gap-2"> <div className="flex w-full items-center gap-2">
<TextFileIcon className="size-4" /> <TextFileIcon className="size-4" />
{initialName} <button
type="button"
className="hover:underline cursor-pointer"
onClick={() => {
setOpenedFile(file)
}}
>
{file.name}
</button>
</div> </div>
) )
} }

View File

@@ -3,7 +3,7 @@ import { baseName, splitPath } from "@fileone/path"
import { useMutation } from "@tanstack/react-query" import { useMutation } from "@tanstack/react-query"
import { Link } from "@tanstack/react-router" import { Link } from "@tanstack/react-router"
import { useMutation as useConvexMutation } from "convex/react" import { useMutation as useConvexMutation } from "convex/react"
import { useSetAtom } from "jotai" import { useAtom, useSetAtom } from "jotai"
import { import {
ChevronDownIcon, ChevronDownIcon,
Loader2Icon, Loader2Icon,
@@ -12,6 +12,7 @@ import {
} from "lucide-react" } from "lucide-react"
import { type ChangeEvent, Fragment, useContext, useRef } from "react" import { type ChangeEvent, Fragment, useContext, useRef } from "react"
import { toast } from "sonner" import { toast } from "sonner"
import { ImagePreviewDialog } from "@/components/image-preview-dialog"
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -32,7 +33,7 @@ import { Button } from "../../components/ui/button"
import { DirectoryPageContext } from "./context" import { DirectoryPageContext } from "./context"
import { DirectoryContentTable } from "./directory-content-table" import { DirectoryContentTable } from "./directory-content-table"
import { RenameFileDialog } from "./rename-file-dialog" import { RenameFileDialog } from "./rename-file-dialog"
import { newItemKindAtom } from "./state" import { newItemKindAtom, openedFileAtom } from "./state"
export function DirectoryPage() { export function DirectoryPage() {
const { directory } = useContext(DirectoryPageContext) const { directory } = useContext(DirectoryPageContext)
@@ -49,6 +50,7 @@ export function DirectoryPage() {
<DirectoryContentTable /> <DirectoryContentTable />
</div> </div>
<RenameFileDialog /> <RenameFileDialog />
<PreviewDialog />
</> </>
) )
} }
@@ -184,3 +186,25 @@ function NewDirectoryItemDropdown() {
</DropdownMenu> </DropdownMenu>
) )
} }
function PreviewDialog() {
const [openedFile, setOpenedFile] = useAtom(openedFileAtom)
if (!openedFile) return null
console.log("openedFile", openedFile)
switch (openedFile.mimeType) {
case "image/jpeg":
case "image/png":
case "image/gif":
return (
<ImagePreviewDialog
file={openedFile}
onClose={() => setOpenedFile(null)}
/>
)
default:
return null
}
}

View File

@@ -1,4 +1,4 @@
import type { Id } from "@fileone/convex/_generated/dataModel" import type { Doc, Id } from "@fileone/convex/_generated/dataModel"
import type { import type {
DirectoryItem, DirectoryItem,
DirectoryItemKind, DirectoryItemKind,
@@ -20,3 +20,5 @@ export const itemBeingRenamedAtom = atom<{
originalItem: DirectoryItem originalItem: DirectoryItem
name: string name: string
} | null>(null) } | null>(null)
export const openedFileAtom = atom<Doc<"files"> | null>(null)