feat(storage): link files to their locations

This commit is contained in:
saltbo
2026-07-23 15:02:15 -04:00
parent 77475d5ceb
commit 7bf5d2f587
9 changed files with 129 additions and 23 deletions
@@ -45,7 +45,7 @@ describe('storage usage breakdown projection', () => {
type: 'application/pdf',
size,
dirtype: DirType.FILE,
parent: '',
parent: name === 'alpha.pdf' ? 'Reports/2026' : '',
object: name,
storageId: 'storage-1',
status: ObjectStatus.ACTIVE,
@@ -54,6 +54,10 @@ describe('storage usage breakdown projection', () => {
const byName = await usage.listItems(orgId, 'documents', 1, 2, 'name', 'asc')
expect(byName.items.map((item) => item.name)).toEqual(['alpha.pdf', 'bravo.pdf'])
expect(byName.items[0]).toMatchObject({
path: 'Reports/2026/alpha.pdf',
parentPath: 'Reports/2026',
})
expect(byName.total).toBe(3)
const bySize = await usage.listItems(orgId, 'documents', 1, 3, 'size', 'desc')
@@ -47,6 +47,17 @@ function itemCategoryCondition(category: StorageUsageCategory) {
return and(isNull(matters.trashedAt), sql`${expression} = ${category}`)
}
function joinPath(parent: string, name: string) {
return parent ? `${parent}/${name}` : name
}
function splitPath(path: string) {
const separator = path.lastIndexOf('/')
return separator === -1
? { name: path, parentPath: '' }
: { name: path.slice(separator + 1), parentPath: path.slice(0, separator) }
}
export function initialStorageUsageProjectionQueries(db: Database, orgId: string, now: Date) {
return STORAGE_USAGE_CATEGORIES.map((category) =>
db
@@ -103,14 +114,19 @@ export function createStorageUsageBreakdownRepo(db: Database): StorageUsageBreak
db.select({ count: count() }).from(imageHostings).where(where),
])
return {
items: rows.map((row) => ({
id: row.id,
name: row.path,
type: row.mime,
size: row.size,
updatedAt: row.createdAt.toISOString(),
source: 'image_hosting' as const,
})),
items: rows.map((row) => {
const location = splitPath(row.path)
return {
id: row.id,
name: location.name,
path: row.path,
parentPath: location.parentPath,
type: row.mime,
size: row.size,
updatedAt: row.createdAt.toISOString(),
source: 'image_hosting' as const,
}
}),
total: totals[0]?.count ?? 0,
}
}
@@ -136,6 +152,8 @@ export function createStorageUsageBreakdownRepo(db: Database): StorageUsageBreak
items: rows.map((row) => ({
id: row.id,
name: row.name,
path: joinPath(row.parent, row.name),
parentPath: row.parent,
type: row.type,
size: row.size ?? 0,
updatedAt: row.updatedAt.toISOString(),
+2
View File
@@ -26,6 +26,8 @@ const usageSchema = z
const itemSchema = z.object({
id: z.string(),
name: z.string(),
path: z.string(),
parentPath: z.string(),
type: z.string(),
size: z.number().int(),
updatedAt: z.string(),
+2
View File
@@ -71,6 +71,8 @@ export interface StorageUsageResponse {
export interface StorageUsageItem {
id: string
name: string
path: string
parentPath: string
type: string
size: number
updatedAt: string
@@ -16,6 +16,7 @@ import {
ChevronRight,
File,
FileText,
FolderOpen,
Image,
Images,
Loader2,
@@ -62,11 +63,13 @@ export function StorageCleanupDialog({
category,
breakdowns,
onCategoryChange,
onOpenLocation,
onOpenChange,
}: {
category: StorageUsageCategory | null
breakdowns: StorageUsageBreakdown[]
onCategoryChange: (category: StorageUsageCategory) => void
onOpenLocation: (item: StorageUsageItem) => void
onOpenChange: (open: boolean) => void
}) {
const { t } = useTranslation()
@@ -302,7 +305,7 @@ export function StorageCleanupDialog({
align="right"
onChange={changeSort}
/>
<TableHead className="h-8 w-11">
<TableHead className="h-8 w-[72px]">
<span className="sr-only">{t('common.actions')}</span>
</TableHead>
</TableRow>
@@ -310,6 +313,12 @@ export function StorageCleanupDialog({
<TableBody>
{items.map((item) => {
const selected = selectedIds.has(item.id)
const locationLabel =
item.source === 'trash'
? t('storage.openTrash')
: item.source === 'image_hosting'
? t('storage.openImageHosting')
: t('storage.openContainingFolder')
return (
<TableRow key={item.id} data-state={selected ? 'selected' : undefined}>
<TableCell className="w-10 px-2 py-1.5 text-center">
@@ -328,8 +337,8 @@ export function StorageCleanupDialog({
<p className="truncate text-xs font-medium" title={item.name}>
{item.name}
</p>
<p className="truncate text-[11px] text-muted-foreground md:hidden">
{formatMimeType(item.type)}
<p className="truncate text-[11px] text-muted-foreground" title={`/${item.path}`}>
/{item.path}
</p>
</div>
</div>
@@ -347,15 +356,27 @@ export function StorageCleanupDialog({
{formatSize(item.size)}
</TableCell>
<TableCell className="py-1.5 pr-2 text-right">
<Button
variant="ghost"
size="icon-sm"
className="size-7 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
aria-label={t('storage.deleteFile', { name: item.name })}
onClick={() => setPendingDeleteItems([item])}
>
<Trash2 className="size-3.5" />
</Button>
<div className="flex justify-end gap-0.5">
<Button
variant="ghost"
size="icon-sm"
className="size-7 text-muted-foreground"
aria-label={locationLabel}
title={locationLabel}
onClick={() => onOpenLocation(item)}
>
<FolderOpen className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
className="size-7 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
aria-label={t('storage.deleteFile', { name: item.name })}
onClick={() => setPendingDeleteItems([item])}
>
<Trash2 className="size-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
)
+3
View File
@@ -1905,6 +1905,9 @@
"storage.columnSize": "Size",
"storage.deleteFile": "Delete {{name}}",
"storage.deleteSelected": "Delete selected",
"storage.openContainingFolder": "Open containing folder",
"storage.openTrash": "Open Trash",
"storage.openImageHosting": "Open Image hosting",
"storage.deleteConfirmTitle": "Permanently delete files?",
"storage.deleteConfirmDescription": "{{count}} files using {{size}} will be permanently deleted. This cannot be undone.",
"storage.deletePermanently": "Delete permanently",
+3
View File
@@ -1905,6 +1905,9 @@
"storage.columnSize": "大小",
"storage.deleteFile": "删除 {{name}}",
"storage.deleteSelected": "删除所选",
"storage.openContainingFolder": "打开所在目录",
"storage.openTrash": "打开回收站",
"storage.openImageHosting": "打开图床",
"storage.deleteConfirmTitle": "永久删除文件?",
"storage.deleteConfirmDescription": "将永久删除 {{count}} 个文件并释放 {{size}} 空间。此操作无法撤销。",
"storage.deletePermanently": "永久删除",
@@ -14,6 +14,8 @@ import {
} from '@/lib/api'
import { StoragePage } from './storage'
const mocks = vi.hoisted(() => ({ navigate: vi.fn() }))
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, values?: Record<string, unknown>) => (values ? `${key}:${Object.values(values).join('/')}` : key),
@@ -23,6 +25,7 @@ vi.mock('react-i18next', () => ({
vi.mock('@tanstack/react-router', () => ({
createFileRoute: () => (options: unknown) => options,
useNavigate: () => mocks.navigate,
}))
vi.mock('@/lib/auth-client', () => ({
@@ -130,6 +133,8 @@ describe('StoragePage', () => {
{
id: 'photo-1',
name: 'one.jpg',
path: 'Photos/one.jpg',
parentPath: 'Photos',
type: 'image/jpeg',
size: 100,
updatedAt: '2026-07-23T00:00:00.000Z',
@@ -150,6 +155,33 @@ describe('StoragePage', () => {
await waitFor(() => expect(listStorageUsageItems).toHaveBeenCalledWith('photos', 1, 20, 'name', 'desc'))
})
it('shows the full path and opens the containing folder', async () => {
vi.mocked(listStorageUsageItems).mockResolvedValue({
items: [
{
id: 'document-1',
name: 'report.pdf',
path: 'Work/Reports/report.pdf',
parentPath: 'Work/Reports',
type: 'application/pdf',
size: 100,
updatedAt: '2026-07-23T00:00:00.000Z',
source: 'files',
},
],
total: 1,
page: 1,
pageSize: 20,
})
renderPage()
const documents = await screen.findAllByText('storage.category.documents')
fireEvent.click(documents[1])
expect(await screen.findByText('/Work/Reports/report.pdf')).toBeTruthy()
fireEvent.click(screen.getByLabelText('storage.openContainingFolder'))
expect(mocks.navigate).toHaveBeenCalledWith({ to: '/files', search: { path: 'Work/Reports' } })
})
it('opens storage plans in a modal instead of the primary page', async () => {
renderPage()
const button = await screen.findByText('storage.expandStorage')
@@ -166,6 +198,8 @@ describe('StoragePage', () => {
{
id: 'photo-1',
name: 'one.jpg',
path: 'Photos/one.jpg',
parentPath: 'Photos',
type: 'image/jpeg',
size: 100,
updatedAt: '2026-07-23T00:00:00.000Z',
@@ -174,6 +208,8 @@ describe('StoragePage', () => {
{
id: 'photo-2',
name: 'two.png',
path: 'Photos/two.png',
parentPath: 'Photos',
type: 'image/png',
size: 200,
updatedAt: '2026-07-23T00:00:00.000Z',
@@ -208,6 +244,8 @@ describe('StoragePage', () => {
{
id: `${category}-1`,
name: `${category}.png`,
path: `${category}/${category}.png`,
parentPath: category,
type: 'image/png',
size: 100,
updatedAt: '2026-07-23T00:00:00.000Z',
+17 -2
View File
@@ -1,6 +1,6 @@
import type { StorageUsageCategory } from '@shared/types'
import type { StorageUsageCategory, StorageUsageItem } from '@shared/types'
import { useQuery } from '@tanstack/react-query'
import { createFileRoute } from '@tanstack/react-router'
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { ChevronRight, Cloud } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -20,6 +20,7 @@ export const Route = createFileRoute('/_authenticated/storage')({
export function StoragePage() {
const { t, i18n } = useTranslation()
const navigate = useNavigate()
const { data: activeOrg } = useActiveOrganization()
const orgId = activeOrg?.id ?? ''
const [selectedCategory, setSelectedCategory] = useState<StorageUsageCategory | null>(null)
@@ -66,6 +67,19 @@ export function StoragePage() {
setPlansOpen(false)
}
function openFileLocation(item: StorageUsageItem) {
setSelectedCategory(null)
if (item.source === 'trash') {
navigate({ to: '/trash' })
return
}
if (item.source === 'image_hosting') {
navigate({ to: '/image-host' })
return
}
navigate({ to: '/files', search: item.parentPath ? { path: item.parentPath } : {} })
}
if (usageQuery.isLoading) {
return <p className="py-20 text-center text-muted-foreground">{t('common.loading')}</p>
}
@@ -182,6 +196,7 @@ export function StoragePage() {
category={selectedCategory}
breakdowns={displayBreakdowns}
onCategoryChange={setSelectedCategory}
onOpenLocation={openFileLocation}
onOpenChange={(open) => !open && setSelectedCategory(null)}
/>