diff --git a/components/files/FilesBreadcrumb.tsx b/components/files/FilesBreadcrumb.tsx index ea8212d..5ab5cf0 100644 --- a/components/files/FilesBreadcrumb.tsx +++ b/components/files/FilesBreadcrumb.tsx @@ -1,11 +1,22 @@ +import { SortColumn, SortOrder } from '/lib/utils/files.ts'; + interface FilesBreadcrumbProps { path: string; isShowingNotes?: boolean; isShowingPhotos?: boolean; fileShareId?: string; + sortBy?: SortColumn; + sortOrder?: SortOrder; } -export default function FilesBreadcrumb({ path, isShowingNotes, isShowingPhotos, fileShareId }: FilesBreadcrumbProps) { +export default function FilesBreadcrumb({ + path, + isShowingNotes, + isShowingPhotos, + fileShareId, + sortBy = 'name', + sortOrder = 'asc' +}: FilesBreadcrumbProps) { let routePath = fileShareId ? `file-share/${fileShareId}` : 'files'; let rootPath = '/'; let itemPluralLabel = 'files'; @@ -29,12 +40,13 @@ export default function FilesBreadcrumb({ path, isShowingNotes, isShowingPhotos, } const pathParts = path.slice(1, -1).split('/'); + const sortParams = `&sortBy=${sortBy}&sortOrder=${sortOrder}`; return (

- {!isShowingNotes && !isShowingPhotos ? All files : null} - {isShowingNotes ? All notes : null} - {isShowingPhotos ? All photos : null} + {!isShowingNotes && !isShowingPhotos ? All files : null} + {isShowingNotes ? All notes : null} + {isShowingPhotos ? All photos : null} {pathParts.map((part, index) => { // Ignore the first directory in special ones if (index === 0 && (isShowingNotes || isShowingPhotos)) { @@ -59,7 +71,7 @@ export default function FilesBreadcrumb({ path, isShowingNotes, isShowingPhotos, return ( <> / - + {decodeURIComponent(part)} diff --git a/components/files/ListFiles.tsx b/components/files/ListFiles.tsx index 371e526..bcb5401 100644 --- a/components/files/ListFiles.tsx +++ b/components/files/ListFiles.tsx @@ -1,6 +1,8 @@ import { join } from '@std/path'; +import type { ComponentChildren } from 'preact'; import { Directory, DirectoryFile } from '/lib/types.ts'; +import { SortColumn, SortOrder } from '/lib/utils/files.ts'; import { humanFileSize, TRASH_PATH } from '/lib/utils/files.ts'; interface ListFilesProps { @@ -21,6 +23,9 @@ interface ListFilesProps { isShowingNotes?: boolean; isShowingPhotos?: boolean; fileShareId?: string; + sortBy?: SortColumn; + sortOrder?: SortOrder; + onClickSort?: (column: SortColumn) => void; } export default function ListFiles( @@ -42,6 +47,9 @@ export default function ListFiles( isShowingNotes, isShowingPhotos, fileShareId, + sortBy = 'name', + sortOrder = 'asc', + onClickSort, }: ListFilesProps, ) { const dateFormatOptions: Intl.DateTimeFormatOptions = { @@ -55,6 +63,39 @@ export default function ListFiles( const dateFormat = new Intl.DateTimeFormat('en-GB', dateFormatOptions); + function getSortIcon(column: SortColumn): string | null { + if (sortBy !== column) return '↕'; // neutral sort icon + return sortOrder === 'asc' ? '↑' : '↓'; + } + + function renderSortableHeader( + label: string, + column: SortColumn, + className?: string, + ): ComponentChildren { + const isActive = sortBy === column; + const iconClass = isActive ? 'text-blue-400' : 'text-slate-400'; + + if (!onClickSort) { + return {label}; + } + + return ( + + + + ); + } + let routePath = fileShareId ? `file-share/${fileShareId}` : 'files'; let itemSingleLabel = 'file'; let itemPluralLabel = 'files'; @@ -104,11 +145,11 @@ export default function ListFiles( /> )} - Name - Last update + {renderSortableHeader('Name', 'name')} + {renderSortableHeader('Last update', 'updated_at', 'w-64')} {isShowingNotes || isShowingPhotos ? null - : Size} + : renderSortableHeader('Size', 'size_in_bytes', 'w-32')} {isShowingPhotos || fileShareId ? null : } @@ -137,7 +178,7 @@ export default function ListFiles( )} (false); @@ -68,6 +73,8 @@ export default function MainFiles( const directories = useSignal(initialDirectories); const files = useSignal(initialFiles); const path = useSignal(initialPath); + const sortBy = useSignal(initialSortBy); + const sortOrder = useSignal(initialSortOrder); const chosenDirectories = useSignal[]>([]); const chosenFiles = useSignal[]>([]); const isAnyItemChosen = chosenDirectories.value.length > 0 || chosenFiles.value.length > 0; @@ -84,6 +91,66 @@ export default function MainFiles( const createShareModal = useSignal<{ isOpen: boolean; filePath: string; password?: string } | null>(null); const manageShareModal = useSignal<{ isOpen: boolean; fileShareId: string } | null>(null); + // Helper functions for sorting persistence + function getSortingKey(path: string): string { + return `file-sort-${path}`; + } + + function loadSortingPreference(path: string): { sortBy: SortColumn; sortOrder: SortOrder } { + if (typeof window === 'undefined') { + return { sortBy: initialSortBy, sortOrder: initialSortOrder }; + } + + try { + const saved = localStorage.getItem(getSortingKey(path)); + if (saved) { + const parsed = JSON.parse(saved); + if (parsed.sortBy && parsed.sortOrder) { + return { sortBy: parsed.sortBy, sortOrder: parsed.sortOrder }; + } + } + } catch (error) { + console.error('Error loading sorting preference:', error); + } + return { sortBy: initialSortBy, sortOrder: initialSortOrder }; + } + + // Initialize sorting from localStorage (prefer saved, fallback to URL params) + const savedPreference = loadSortingPreference(initialPath); + sortBy.value = savedPreference.sortBy; + sortOrder.value = savedPreference.sortOrder; + + function saveSortingPreference(path: string, sortBy: SortColumn, sortOrder: SortOrder) { + if (typeof window === 'undefined') return; + + try { + localStorage.setItem(getSortingKey(path), JSON.stringify({ sortBy, sortOrder })); + } catch (error) { + console.error('Error saving sorting preference:', error); + } + } + + function onClickSort(column: SortColumn) { + let newSortOrder: SortOrder = 'asc'; + + if (sortBy.value === column) { + // Toggle sort order if clicking the same column + newSortOrder = sortOrder.value === 'asc' ? 'desc' : 'asc'; + } else { + // Default to ascending for new columns + newSortOrder = 'asc'; + } + + // Save to localStorage + saveSortingPreference(path.value, column, newSortOrder); + + // Update URL and navigate to trigger re-render with new sorting + const url = new URL(window.location.href); + url.searchParams.set('sortBy', column); + url.searchParams.set('sortOrder', newSortOrder); + window.location.href = url.toString(); + } + function onClickUploadFile(uploadDirectory = false) { const fileInput = document.createElement('input'); fileInput.type = 'file'; @@ -759,7 +826,12 @@ export default function MainFiles(
- + {!fileShareId ? ( @@ -840,6 +912,9 @@ export default function MainFiles( onClickCreateShare={isFileSharingAllowed ? onClickCreateShare : undefined} onClickOpenManageShare={isFileSharingAllowed ? onClickOpenManageShare : undefined} fileShareId={fileShareId} + sortBy={sortBy.value} + sortOrder={sortOrder.value} + onClickSort={onClickSort} /> ); } diff --git a/lib/models/files.ts b/lib/models/files.ts index f8de55a..0ea36b4 100644 --- a/lib/models/files.ts +++ b/lib/models/files.ts @@ -4,7 +4,7 @@ import { Cookie, getCookies, setCookie } from '@std/http'; import { AppConfig } from '/lib/config.ts'; import { Directory, DirectoryFile, FileShare } from '/lib/types.ts'; -import { sortDirectoriesByName, sortEntriesByName, sortFilesByName, TRASH_PATH } from '/lib/utils/files.ts'; +import { sortDirectoriesByName, sortEntriesByName, sortFilesByName, sortDirectories, sortFiles, SortOptions, TRASH_PATH } from '/lib/utils/files.ts'; import Database, { sql } from '/lib/interfaces/database.ts'; import { COOKIE_NAME as AUTH_COOKIE_NAME, @@ -21,7 +21,7 @@ const COOKIE_NAME = `${AUTH_COOKIE_NAME}-file-share`; const db = new Database(); export class DirectoryModel { - static async list(userId: string, path: string): Promise { + static async list(userId: string, path: string, sortOptions?: SortOptions): Promise { await ensureUserPathIsValidAndSecurelyAccessible(userId, path); const rootPath = join(await AppConfig.getFilesRootPath(), userId, path); @@ -53,9 +53,12 @@ export class DirectoryModel { directories.push(directory); } - directories.sort(sortDirectoriesByName); - - return directories; + if (sortOptions) { + return sortDirectories(directories, sortOptions); + } else { + directories.sort(sortDirectoriesByName); + return directories; + } } static async create(userId: string, path: string, name: string): Promise { @@ -167,7 +170,7 @@ export class DirectoryModel { } export class FileModel { - static async list(userId: string, path: string): Promise { + static async list(userId: string, path: string, sortOptions?: SortOptions): Promise { await ensureUserPathIsValidAndSecurelyAccessible(userId, path); const rootPath = join(await AppConfig.getFilesRootPath(), userId, path); @@ -197,9 +200,12 @@ export class FileModel { files.push(file); } - files.sort(sortFilesByName); - - return files; + if (sortOptions) { + return sortFiles(files, sortOptions); + } else { + files.sort(sortFilesByName); + return files; + } } static async create( @@ -598,7 +604,13 @@ export async function ensureUserPathIsValidAndSecurelyAccessible(userId: string, const resolvedFullPath = `${resolve(fullPath)}/`; - if (!resolvedFullPath.startsWith(userRootPath)) { + console.log({ userRootPath, fullPath, resolvedFullPath }); + + // Normalize path separators for consistent comparison on Windows + const normalizedUserRootPath = userRootPath.replace(/\\/g, '/'); + const normalizedResolvedFullPath = resolvedFullPath.replace(/\\/g, '/'); + + if (!normalizedResolvedFullPath.startsWith(normalizedUserRootPath)) { throw new Error('Invalid file path'); } } @@ -626,7 +638,11 @@ export async function ensureFileSharePathIsValidAndSecurelyAccessible( const resolvedFullPath = `${resolve(fullPath)}/`; - if (!resolvedFullPath.startsWith(fileShareRootPath)) { + // Normalize path separators for consistent comparison on Windows + const normalizedFileShareRootPath = fileShareRootPath.replace(/\\/g, '/'); + const normalizedResolvedFullPath = resolvedFullPath.replace(/\\/g, '/'); + + if (!normalizedResolvedFullPath.startsWith(normalizedFileShareRootPath)) { throw new Error('Invalid file path'); } } diff --git a/lib/utils/files.ts b/lib/utils/files.ts index c6a2759..8810807 100644 --- a/lib/utils/files.ts +++ b/lib/utils/files.ts @@ -20,6 +20,14 @@ export function humanFileSize(bytes: number) { return `${bytes.toFixed(2)} ${units[unitIndex]}`; } +export type SortColumn = 'name' | 'updated_at' | 'size_in_bytes'; +export type SortOrder = 'asc' | 'desc'; + +export interface SortOptions { + sortBy: SortColumn; + sortOrder: SortOrder; +} + export function sortEntriesByName(entryA: Deno.DirEntry, entryB: Deno.DirEntry) { const nameA = entryA.name.toLowerCase(); const nameB = entryB.name.toLowerCase(); @@ -64,3 +72,47 @@ export function sortFilesByName(fileA: DirectoryFile, fileB: DirectoryFile) { return 0; } + +export function sortDirectories(directories: Directory[], options: SortOptions): Directory[] { + const sorted = [...directories].sort((a, b) => { + let result = 0; + + switch (options.sortBy) { + case 'name': + result = a.directory_name.toLowerCase().localeCompare(b.directory_name.toLowerCase()); + break; + case 'updated_at': + result = new Date(a.updated_at).getTime() - new Date(b.updated_at).getTime(); + break; + case 'size_in_bytes': + result = a.size_in_bytes - b.size_in_bytes; + break; + } + + return options.sortOrder === 'desc' ? -result : result; + }); + + return sorted; +} + +export function sortFiles(files: DirectoryFile[], options: SortOptions): DirectoryFile[] { + const sorted = [...files].sort((a, b) => { + let result = 0; + + switch (options.sortBy) { + case 'name': + result = a.file_name.toLowerCase().localeCompare(b.file_name.toLowerCase()); + break; + case 'updated_at': + result = new Date(a.updated_at).getTime() - new Date(b.updated_at).getTime(); + break; + case 'size_in_bytes': + result = a.size_in_bytes - b.size_in_bytes; + break; + } + + return options.sortOrder === 'desc' ? -result : result; + }); + + return sorted; +} diff --git a/routes/api/files/get-directories.tsx b/routes/api/files/get-directories.tsx index 57fb679..3ad8892 100644 --- a/routes/api/files/get-directories.tsx +++ b/routes/api/files/get-directories.tsx @@ -2,12 +2,15 @@ import { Handlers } from 'fresh/server.ts'; import { Directory, FreshContextState } from '/lib/types.ts'; import { DirectoryModel } from '/lib/models/files.ts'; +import { SortColumn, SortOrder } from '/lib/utils/files.ts'; interface Data {} export interface RequestBody { parentPath: string; directoryPathToExclude?: string; + sortBy?: SortColumn; + sortOrder?: SortOrder; } export interface ResponseBody { @@ -29,9 +32,14 @@ export const handler: Handlers = { return new Response('Bad Request', { status: 400 }); } + const sortOptions = (requestBody.sortBy && requestBody.sortOrder) + ? { sortBy: requestBody.sortBy, sortOrder: requestBody.sortOrder } + : undefined; + const directories = await DirectoryModel.list( context.state.user.id, requestBody.parentPath, + sortOptions, ); const filteredDirectories = requestBody.directoryPathToExclude diff --git a/routes/api/files/get.tsx b/routes/api/files/get.tsx index 1d81ae6..5ef0030 100644 --- a/routes/api/files/get.tsx +++ b/routes/api/files/get.tsx @@ -2,11 +2,14 @@ import { Handlers } from 'fresh/server.ts'; import { DirectoryFile, FreshContextState } from '/lib/types.ts'; import { FileModel } from '/lib/models/files.ts'; +import { SortColumn, SortOrder } from '/lib/utils/files.ts'; interface Data {} export interface RequestBody { parentPath: string; + sortBy?: SortColumn; + sortOrder?: SortOrder; } export interface ResponseBody { @@ -28,9 +31,14 @@ export const handler: Handlers = { return new Response('Bad Request', { status: 400 }); } + const sortOptions = (requestBody.sortBy && requestBody.sortOrder) + ? { sortBy: requestBody.sortBy, sortOrder: requestBody.sortOrder } + : undefined; + const files = await FileModel.list( context.state.user.id, requestBody.parentPath, + sortOptions, ); const responseBody: ResponseBody = { success: true, files }; diff --git a/routes/files.tsx b/routes/files.tsx index dd37e77..59a6003 100644 --- a/routes/files.tsx +++ b/routes/files.tsx @@ -3,6 +3,7 @@ import { Handlers, PageProps } from 'fresh/server.ts'; import { Directory, DirectoryFile, FreshContextState } from '/lib/types.ts'; import { DirectoryModel, FileModel } from '/lib/models/files.ts'; import { AppConfig } from '/lib/config.ts'; +import { SortColumn, SortOrder } from '/lib/utils/files.ts'; import FilesWrapper from '/islands/files/FilesWrapper.tsx'; interface Data { @@ -11,6 +12,8 @@ interface Data { currentPath: string; baseUrl: string; isFileSharingAllowed: boolean; + sortBy: SortColumn; + sortOrder: SortOrder; } export const handler: Handlers = { @@ -35,9 +38,22 @@ export const handler: Handlers = { currentPath = `${currentPath}/`; } - const userDirectories = await DirectoryModel.list(context.state.user.id, currentPath); + // Get sort parameters + const sortBy = (searchParams.get('sortBy') as SortColumn) || 'name'; + const sortOrder = (searchParams.get('sortOrder') as SortOrder) || 'asc'; + + // Validate sort parameters + const validSortColumns: SortColumn[] = ['name', 'updated_at', 'size_in_bytes']; + const validSortOrders: SortOrder[] = ['asc', 'desc']; + + const finalSortBy = validSortColumns.includes(sortBy) ? sortBy : 'name'; + const finalSortOrder = validSortOrders.includes(sortOrder) ? sortOrder : 'asc'; + + const sortOptions = { sortBy: finalSortBy, sortOrder: finalSortOrder }; - const userFiles = await FileModel.list(context.state.user.id, currentPath); + const userDirectories = await DirectoryModel.list(context.state.user.id, currentPath, sortOptions); + + const userFiles = await FileModel.list(context.state.user.id, currentPath, sortOptions); const isPublicFileSharingAllowed = await AppConfig.isPublicFileSharingAllowed(); @@ -47,6 +63,8 @@ export const handler: Handlers = { currentPath, baseUrl, isFileSharingAllowed: isPublicFileSharingAllowed, + sortBy: finalSortBy, + sortOrder: finalSortOrder, }); }, }; @@ -60,6 +78,8 @@ export default function FilesPage({ data }: PageProps) initialPath={data.currentPath} baseUrl={data.baseUrl} isFileSharingAllowed={data.isFileSharingAllowed} + initialSortBy={data.sortBy} + initialSortOrder={data.sortOrder} /> );