Compare commits

...

5 Commits

11 changed files with 279 additions and 21 deletions

View File

@@ -0,0 +1,21 @@
import { cn } from "@/lib/utils"
function MiddleTruncatedText({
children,
className,
}: {
children: string
className?: string
}) {
const LAST_PART_LENGTH = 3
const lastPart = children.slice(children.length - LAST_PART_LENGTH)
const firstPart = children.slice(0, children.length - LAST_PART_LENGTH)
return (
<p className={cn("max-w-full flex", className)}>
<span className="flex-1 truncate">{firstPart}</span>
<span className="w-min">{lastPart}</span>
</p>
)
}
export { MiddleTruncatedText }

View File

@@ -3,8 +3,8 @@ import { Link, useLocation } from "@tanstack/react-router"
import { useQuery as useConvexQuery } from "convex/react" import { useQuery as useConvexQuery } from "convex/react"
import { useAtomValue } from "jotai" import { useAtomValue } from "jotai"
import { import {
ClockIcon,
FilesIcon, FilesIcon,
HomeIcon,
LogOutIcon, LogOutIcon,
SettingsIcon, SettingsIcon,
TrashIcon, TrashIcon,
@@ -66,10 +66,10 @@ function MainSidebarMenu() {
return ( return (
<SidebarMenu> <SidebarMenu>
<SidebarMenuItem> <SidebarMenuItem>
<SidebarMenuButton asChild isActive={isActive("/")}> <SidebarMenuButton asChild isActive={isActive("/recent")}>
<Link to="/"> <Link to="/recent">
<HomeIcon /> <ClockIcon />
<span>Home</span> <span>Recent</span>
</Link> </Link>
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>

View File

@@ -0,0 +1,84 @@
import type { Doc, Id } from "@fileone/convex/dataModel"
import { memo, useCallback } from "react"
import { TextFileIcon } from "@/components/icons/text-file-icon"
import { MiddleTruncatedText } from "@/components/ui/middle-truncated-text"
import { cn } from "@/lib/utils"
export type FileGridSelection = Set<Id<"files">>
export function FileGrid({
files,
selectedFiles = new Set(),
onSelectionChange,
onContextMenu,
}: {
files: Doc<"files">[]
selectedFiles?: FileGridSelection
onSelectionChange?: (selection: FileGridSelection) => void
onContextMenu?: (file: Doc<"files">, event: React.MouseEvent) => void
}) {
const onItemSelect = useCallback(
(file: Doc<"files">) => {
onSelectionChange?.(new Set([file._id]))
},
[onSelectionChange],
)
const onItemContextMenu = useCallback(
(file: Doc<"files">, event: React.MouseEvent) => {
onContextMenu?.(file, event)
onSelectionChange?.(new Set([file._id]))
},
[onContextMenu, onSelectionChange],
)
return (
<div className="grid auto-cols-max grid-flow-col gap-3">
{files.map((file) => (
<FileGridItem
selected={selectedFiles.has(file._id)}
key={file._id}
file={file}
onSelect={onItemSelect}
onContextMenu={onItemContextMenu}
/>
))}
</div>
)
}
const FileGridItem = memo(function FileGridItem({
selected,
file,
onSelect,
onContextMenu,
}: {
selected: boolean
file: Doc<"files">
onSelect?: (file: Doc<"files">) => void
onContextMenu?: (file: Doc<"files">, event: React.MouseEvent) => void
}) {
return (
<button
type="button"
key={file._id}
className={cn(
"flex flex-col gap-2 items-center justify-center w-24 p-[calc(var(--spacing)*1+1px)] rounded-md",
{ "bg-muted border border-border p-1": selected },
)}
onClick={() => {
onSelect?.(file)
}}
onContextMenu={(event) => {
onContextMenu?.(file, event)
}}
>
<TextFileIcon className="size-10" />
<MiddleTruncatedText className="text-sm">
{file.name}
</MiddleTruncatedText>
</button>
)
})
export { FileGridItem }

View File

@@ -15,6 +15,7 @@ import { Route as AuthenticatedRouteImport } from './routes/_authenticated'
import { Route as AuthenticatedIndexRouteImport } from './routes/_authenticated/index' import { Route as AuthenticatedIndexRouteImport } from './routes/_authenticated/index'
import { Route as LoginCallbackRouteImport } from './routes/login_.callback' import { Route as LoginCallbackRouteImport } from './routes/login_.callback'
import { Route as AuthenticatedSidebarLayoutRouteImport } from './routes/_authenticated/_sidebar-layout' import { Route as AuthenticatedSidebarLayoutRouteImport } from './routes/_authenticated/_sidebar-layout'
import { Route as AuthenticatedSidebarLayoutRecentRouteImport } from './routes/_authenticated/_sidebar-layout/recent'
import { Route as AuthenticatedSidebarLayoutHomeRouteImport } from './routes/_authenticated/_sidebar-layout/home' import { Route as AuthenticatedSidebarLayoutHomeRouteImport } from './routes/_authenticated/_sidebar-layout/home'
import { Route as AuthenticatedSidebarLayoutDirectoriesDirectoryIdRouteImport } from './routes/_authenticated/_sidebar-layout/directories.$directoryId' import { Route as AuthenticatedSidebarLayoutDirectoriesDirectoryIdRouteImport } from './routes/_authenticated/_sidebar-layout/directories.$directoryId'
import { Route as AuthenticatedSidebarLayoutTrashDirectoriesDirectoryIdRouteImport } from './routes/_authenticated/_sidebar-layout/trash.directories.$directoryId' import { Route as AuthenticatedSidebarLayoutTrashDirectoriesDirectoryIdRouteImport } from './routes/_authenticated/_sidebar-layout/trash.directories.$directoryId'
@@ -48,6 +49,12 @@ const AuthenticatedSidebarLayoutRoute =
id: '/_sidebar-layout', id: '/_sidebar-layout',
getParentRoute: () => AuthenticatedRoute, getParentRoute: () => AuthenticatedRoute,
} as any) } as any)
const AuthenticatedSidebarLayoutRecentRoute =
AuthenticatedSidebarLayoutRecentRouteImport.update({
id: '/recent',
path: '/recent',
getParentRoute: () => AuthenticatedSidebarLayoutRoute,
} as any)
const AuthenticatedSidebarLayoutHomeRoute = const AuthenticatedSidebarLayoutHomeRoute =
AuthenticatedSidebarLayoutHomeRouteImport.update({ AuthenticatedSidebarLayoutHomeRouteImport.update({
id: '/home', id: '/home',
@@ -73,6 +80,7 @@ export interface FileRoutesByFullPath {
'/login/callback': typeof LoginCallbackRoute '/login/callback': typeof LoginCallbackRoute
'/': typeof AuthenticatedIndexRoute '/': typeof AuthenticatedIndexRoute
'/home': typeof AuthenticatedSidebarLayoutHomeRoute '/home': typeof AuthenticatedSidebarLayoutHomeRoute
'/recent': typeof AuthenticatedSidebarLayoutRecentRoute
'/directories/$directoryId': typeof AuthenticatedSidebarLayoutDirectoriesDirectoryIdRoute '/directories/$directoryId': typeof AuthenticatedSidebarLayoutDirectoriesDirectoryIdRoute
'/trash/directories/$directoryId': typeof AuthenticatedSidebarLayoutTrashDirectoriesDirectoryIdRoute '/trash/directories/$directoryId': typeof AuthenticatedSidebarLayoutTrashDirectoriesDirectoryIdRoute
} }
@@ -82,6 +90,7 @@ export interface FileRoutesByTo {
'/login/callback': typeof LoginCallbackRoute '/login/callback': typeof LoginCallbackRoute
'/': typeof AuthenticatedIndexRoute '/': typeof AuthenticatedIndexRoute
'/home': typeof AuthenticatedSidebarLayoutHomeRoute '/home': typeof AuthenticatedSidebarLayoutHomeRoute
'/recent': typeof AuthenticatedSidebarLayoutRecentRoute
'/directories/$directoryId': typeof AuthenticatedSidebarLayoutDirectoriesDirectoryIdRoute '/directories/$directoryId': typeof AuthenticatedSidebarLayoutDirectoriesDirectoryIdRoute
'/trash/directories/$directoryId': typeof AuthenticatedSidebarLayoutTrashDirectoriesDirectoryIdRoute '/trash/directories/$directoryId': typeof AuthenticatedSidebarLayoutTrashDirectoriesDirectoryIdRoute
} }
@@ -94,6 +103,7 @@ export interface FileRoutesById {
'/login_/callback': typeof LoginCallbackRoute '/login_/callback': typeof LoginCallbackRoute
'/_authenticated/': typeof AuthenticatedIndexRoute '/_authenticated/': typeof AuthenticatedIndexRoute
'/_authenticated/_sidebar-layout/home': typeof AuthenticatedSidebarLayoutHomeRoute '/_authenticated/_sidebar-layout/home': typeof AuthenticatedSidebarLayoutHomeRoute
'/_authenticated/_sidebar-layout/recent': typeof AuthenticatedSidebarLayoutRecentRoute
'/_authenticated/_sidebar-layout/directories/$directoryId': typeof AuthenticatedSidebarLayoutDirectoriesDirectoryIdRoute '/_authenticated/_sidebar-layout/directories/$directoryId': typeof AuthenticatedSidebarLayoutDirectoriesDirectoryIdRoute
'/_authenticated/_sidebar-layout/trash/directories/$directoryId': typeof AuthenticatedSidebarLayoutTrashDirectoriesDirectoryIdRoute '/_authenticated/_sidebar-layout/trash/directories/$directoryId': typeof AuthenticatedSidebarLayoutTrashDirectoriesDirectoryIdRoute
} }
@@ -105,6 +115,7 @@ export interface FileRouteTypes {
| '/login/callback' | '/login/callback'
| '/' | '/'
| '/home' | '/home'
| '/recent'
| '/directories/$directoryId' | '/directories/$directoryId'
| '/trash/directories/$directoryId' | '/trash/directories/$directoryId'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
@@ -114,6 +125,7 @@ export interface FileRouteTypes {
| '/login/callback' | '/login/callback'
| '/' | '/'
| '/home' | '/home'
| '/recent'
| '/directories/$directoryId' | '/directories/$directoryId'
| '/trash/directories/$directoryId' | '/trash/directories/$directoryId'
id: id:
@@ -125,6 +137,7 @@ export interface FileRouteTypes {
| '/login_/callback' | '/login_/callback'
| '/_authenticated/' | '/_authenticated/'
| '/_authenticated/_sidebar-layout/home' | '/_authenticated/_sidebar-layout/home'
| '/_authenticated/_sidebar-layout/recent'
| '/_authenticated/_sidebar-layout/directories/$directoryId' | '/_authenticated/_sidebar-layout/directories/$directoryId'
| '/_authenticated/_sidebar-layout/trash/directories/$directoryId' | '/_authenticated/_sidebar-layout/trash/directories/$directoryId'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
@@ -180,6 +193,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedSidebarLayoutRouteImport preLoaderRoute: typeof AuthenticatedSidebarLayoutRouteImport
parentRoute: typeof AuthenticatedRoute parentRoute: typeof AuthenticatedRoute
} }
'/_authenticated/_sidebar-layout/recent': {
id: '/_authenticated/_sidebar-layout/recent'
path: '/recent'
fullPath: '/recent'
preLoaderRoute: typeof AuthenticatedSidebarLayoutRecentRouteImport
parentRoute: typeof AuthenticatedSidebarLayoutRoute
}
'/_authenticated/_sidebar-layout/home': { '/_authenticated/_sidebar-layout/home': {
id: '/_authenticated/_sidebar-layout/home' id: '/_authenticated/_sidebar-layout/home'
path: '/home' path: '/home'
@@ -206,6 +226,7 @@ declare module '@tanstack/react-router' {
interface AuthenticatedSidebarLayoutRouteChildren { interface AuthenticatedSidebarLayoutRouteChildren {
AuthenticatedSidebarLayoutHomeRoute: typeof AuthenticatedSidebarLayoutHomeRoute AuthenticatedSidebarLayoutHomeRoute: typeof AuthenticatedSidebarLayoutHomeRoute
AuthenticatedSidebarLayoutRecentRoute: typeof AuthenticatedSidebarLayoutRecentRoute
AuthenticatedSidebarLayoutDirectoriesDirectoryIdRoute: typeof AuthenticatedSidebarLayoutDirectoriesDirectoryIdRoute AuthenticatedSidebarLayoutDirectoriesDirectoryIdRoute: typeof AuthenticatedSidebarLayoutDirectoriesDirectoryIdRoute
AuthenticatedSidebarLayoutTrashDirectoriesDirectoryIdRoute: typeof AuthenticatedSidebarLayoutTrashDirectoriesDirectoryIdRoute AuthenticatedSidebarLayoutTrashDirectoriesDirectoryIdRoute: typeof AuthenticatedSidebarLayoutTrashDirectoriesDirectoryIdRoute
} }
@@ -213,6 +234,8 @@ interface AuthenticatedSidebarLayoutRouteChildren {
const AuthenticatedSidebarLayoutRouteChildren: AuthenticatedSidebarLayoutRouteChildren = const AuthenticatedSidebarLayoutRouteChildren: AuthenticatedSidebarLayoutRouteChildren =
{ {
AuthenticatedSidebarLayoutHomeRoute: AuthenticatedSidebarLayoutHomeRoute, AuthenticatedSidebarLayoutHomeRoute: AuthenticatedSidebarLayoutHomeRoute,
AuthenticatedSidebarLayoutRecentRoute:
AuthenticatedSidebarLayoutRecentRoute,
AuthenticatedSidebarLayoutDirectoriesDirectoryIdRoute: AuthenticatedSidebarLayoutDirectoriesDirectoryIdRoute:
AuthenticatedSidebarLayoutDirectoriesDirectoryIdRoute, AuthenticatedSidebarLayoutDirectoriesDirectoryIdRoute,
AuthenticatedSidebarLayoutTrashDirectoriesDirectoryIdRoute: AuthenticatedSidebarLayoutTrashDirectoriesDirectoryIdRoute:

View File

@@ -196,7 +196,6 @@ function _DirectoryContentTable() {
const { mutate: openFile } = useMutation({ const { mutate: openFile } = useMutation({
mutationFn: useConvexMutation(api.filesystem.openFile), mutationFn: useConvexMutation(api.filesystem.openFile),
onSuccess: (openedFile: OpenedFile) => { onSuccess: (openedFile: OpenedFile) => {
console.log("openedFile", openedFile)
setOpenedFile(openedFile) setOpenedFile(openedFile)
}, },
onError: (error) => { onError: (error) => {

View File

@@ -0,0 +1,119 @@
import { api } from "@fileone/convex/api"
import type { Doc } from "@fileone/convex/dataModel"
import { newFileHandle } from "@fileone/convex/filesystem"
import { useMutation } from "@tanstack/react-query"
import { createFileRoute, Link } from "@tanstack/react-router"
import {
useMutation as useConvexMutation,
useQuery as useConvexQuery,
} from "convex/react"
import { atom, useAtom, useAtomValue, useSetAtom } from "jotai"
import { FolderInputIcon, TrashIcon } from "lucide-react"
import { useCallback } from "react"
import { toast } from "sonner"
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from "@/components/ui/context-menu"
import { backgroundTaskProgressAtom } from "@/dashboard/state"
import type { FileGridSelection } from "@/files/file-grid"
import { FileGrid } from "@/files/file-grid"
import { formatError } from "@/lib/error"
export const Route = createFileRoute("/_authenticated/_sidebar-layout/recent")({
component: RouteComponent,
})
const selectedFilesAtom = atom(new Set() as FileGridSelection)
const contextMenuTargetItem = atom<Doc<"files"> | null>(null)
function RouteComponent() {
return (
<main className="p-4">
<RecentFilesContextMenu>
<RecentFilesGrid />
</RecentFilesContextMenu>
</main>
)
}
function RecentFilesGrid() {
const recentFiles = useConvexQuery(api.filesystem.fetchRecentFiles, {
limit: 100,
})
const [selectedFiles, setSelectedFiles] = useAtom(selectedFilesAtom)
const setContextMenuTargetItem = useSetAtom(contextMenuTargetItem)
const handleContextMenu = useCallback(
(file: Doc<"files">, _event: React.MouseEvent) => {
setContextMenuTargetItem(file)
},
[setContextMenuTargetItem],
)
return (
<FileGrid
files={recentFiles ?? []}
selectedFiles={selectedFiles}
onSelectionChange={setSelectedFiles}
onContextMenu={handleContextMenu}
/>
)
}
function RecentFilesContextMenu({ children }: { children: React.ReactNode }) {
const targetItem = useAtomValue(contextMenuTargetItem)
const setBackgroundTaskProgress = useSetAtom(backgroundTaskProgressAtom)
const { mutate: moveToTrash } = useMutation({
mutationFn: useConvexMutation(api.filesystem.moveToTrash),
onMutate: () => {
setBackgroundTaskProgress({
label: "Moving to trash…",
})
},
onSuccess: () => {
setBackgroundTaskProgress(null)
toast.success("Moved to trash")
},
onError: (error) => {
toast.error("Failed to move to trash", {
description: formatError(error),
})
},
})
return (
<ContextMenu>
<ContextMenuTrigger asChild>
<div>{children}</div>
</ContextMenuTrigger>
{targetItem && (
<ContextMenuContent>
<ContextMenuItem>
<Link
to={`/directories/${targetItem.directoryId}`}
className="flex flex-row items-center gap-2"
>
<FolderInputIcon />
Open in directory
</Link>
</ContextMenuItem>
<ContextMenuItem
variant="destructive"
onClick={() => {
moveToTrash({
handles: [newFileHandle(targetItem._id)],
})
}}
>
<TrashIcon />
Move to trash
</ContextMenuItem>
</ContextMenuContent>
)}
</ContextMenu>
)
}

View File

@@ -36,7 +36,6 @@ import { DirectoryPageContext } from "@/directories/directory-page/context"
import { DirectoryContentTable } from "@/directories/directory-page/directory-content-table" import { DirectoryContentTable } from "@/directories/directory-page/directory-content-table"
import { DirectoryPageSkeleton } from "@/directories/directory-page/directory-page-skeleton" import { DirectoryPageSkeleton } from "@/directories/directory-page/directory-page-skeleton"
import { DirectoryPathBreadcrumb } from "@/directories/directory-path-breadcrumb" import { DirectoryPathBreadcrumb } from "@/directories/directory-path-breadcrumb"
import { FilePreviewDialog } from "@/files/file-preview-dialog"
import type { FileDragInfo } from "@/files/use-file-drop" import type { FileDragInfo } from "@/files/use-file-drop"
import { backgroundTaskProgressAtom } from "../../../dashboard/state" import { backgroundTaskProgressAtom } from "../../../dashboard/state"
@@ -136,18 +135,6 @@ function RouteComponent() {
<DeleteConfirmationDialog /> <DeleteConfirmationDialog />
<EmptyTrashConfirmationDialog /> <EmptyTrashConfirmationDialog />
<WithAtom atom={openedFileAtom}>
{(openedFile, setOpenedFile) => {
if (!openedFile) return null
return (
<FilePreviewDialog
file={openedFile}
onClose={() => setOpenedFile(null)}
/>
)
}}
</WithAtom>
</DirectoryPageContext> </DirectoryPageContext>
) )
} }

View File

@@ -5,5 +5,5 @@ export const Route = createFileRoute("/_authenticated/")({
}) })
function RouteComponent() { function RouteComponent() {
return <Navigate replace to="/home" /> return <Navigate replace to="/recent" />
} }

View File

@@ -188,3 +188,12 @@ export const openFile = authenticatedMutation({
return await FileSystem.openFile(ctx, { fileId }) return await FileSystem.openFile(ctx, { fileId })
}, },
}) })
export const fetchRecentFiles = authenticatedQuery({
args: {
limit: v.number(),
},
handler: async (ctx, { limit }) => {
return await FileSystem.fetchRecentFiles(ctx, { limit })
},
})

View File

@@ -265,3 +265,19 @@ export async function openFile(
shareToken: newFileShare.shareToken, shareToken: newFileShare.shareToken,
} }
} }
export async function fetchRecentFiles(
ctx: AuthenticatedQueryCtx,
{ limit }: { limit: number },
) {
return await ctx.db
.query("files")
.withIndex("byLastAccessedAt", (q) =>
q
.eq("userId", ctx.user._id)
.eq("deletedAt", undefined)
.gte("lastAccessedAt", 0),
)
.order("desc")
.take(limit)
}

View File

@@ -17,7 +17,7 @@ const schema = defineSchema({
.index("byDirectoryId", ["userId", "directoryId", "deletedAt"]) .index("byDirectoryId", ["userId", "directoryId", "deletedAt"])
.index("byUserId", ["userId", "deletedAt"]) .index("byUserId", ["userId", "deletedAt"])
.index("byDeletedAt", ["deletedAt"]) .index("byDeletedAt", ["deletedAt"])
.index("byLastAccessedAt", ["userId", "lastAccessedAt"]) .index("byLastAccessedAt", ["userId", "deletedAt", "lastAccessedAt"])
.index("uniqueFileInDirectory", [ .index("uniqueFileInDirectory", [
"userId", "userId",
"directoryId", "directoryId",