Implement column sorting

This commit is contained in:
Tilman 2025-09-30 21:21:26 +02:00
parent 08907295e9
commit 10e857965f
9 changed files with 262 additions and 23 deletions

View file

@ -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 (
<h3 class='text-base font-semibold text-white whitespace-nowrap mr-2'>
{!isShowingNotes && !isShowingPhotos ? <a href={`/${routePath}?path=/`}>All files</a> : null}
{isShowingNotes ? <a href={`/notes?path=/Notes/`}>All notes</a> : null}
{isShowingPhotos ? <a href={`/photos?path=/Photos/`}>All photos</a> : null}
{!isShowingNotes && !isShowingPhotos ? <a href={`/${routePath}?path=/${sortParams}`}>All files</a> : null}
{isShowingNotes ? <a href={`/notes?path=/Notes/${sortParams}`}>All notes</a> : null}
{isShowingPhotos ? <a href={`/photos?path=/Photos/${sortParams}`}>All photos</a> : 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 (
<>
<span class='ml-2 text-xs'>/</span>
<a href={`/${routePath}?path=/${encodeURIComponent(fullPathForPart.join('/'))}/`} class='ml-2'>
<a href={`/${routePath}?path=/${encodeURIComponent(fullPathForPart.join('/'))}/${sortParams}`} class='ml-2'>
{decodeURIComponent(part)}
</a>
</>

View file

@ -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 <th scope='col' class={`px-6 py-4 font-medium text-white ${className || ''}`}>{label}</th>;
}
return (
<th scope='col' class={`px-6 py-4 font-medium text-white ${className || ''}`}>
<button
class={`flex items-center justify-between w-full text-left hover:text-blue-300 ${isActive ? 'text-blue-400' : ''}`}
onClick={() => onClickSort(column)}
type='button'
>
<span>{label}</span>
<span class={`ml-1 text-xs ${iconClass}`}>
{getSortIcon(column)}
</span>
</button>
</th>
);
}
let routePath = fileShareId ? `file-share/${fileShareId}` : 'files';
let itemSingleLabel = 'file';
let itemPluralLabel = 'files';
@ -104,11 +145,11 @@ export default function ListFiles(
/>
</th>
)}
<th scope='col' class='px-6 py-4 font-medium text-white'>Name</th>
<th scope='col' class='px-6 py-4 font-medium text-white w-64'>Last update</th>
{renderSortableHeader('Name', 'name')}
{renderSortableHeader('Last update', 'updated_at', 'w-64')}
{isShowingNotes || isShowingPhotos
? null
: <th scope='col' class='px-6 py-4 font-medium text-white w-32'>Size</th>}
: renderSortableHeader('Size', 'size_in_bytes', 'w-32')}
{isShowingPhotos || fileShareId
? null
: <th scope='col' class='px-6 py-4 font-medium text-white w-24'></th>}
@ -137,7 +178,7 @@ export default function ListFiles(
)}
<td class='flex gap-3 px-6 py-4'>
<a
href={`/${routePath}?path=${encodeURIComponent(fullPath)}`}
href={`/${routePath}?path=${encodeURIComponent(fullPath)}&sortBy=${sortBy}&sortOrder=${sortOrder}`}
class='flex items-center font-normal text-white'
>
<img

View file

@ -1,6 +1,7 @@
import { useSignal } from '@preact/signals';
import { Directory, DirectoryFile } from '/lib/types.ts';
import { SortColumn, SortOrder } from '/lib/utils/files.ts';
import { ResponseBody as UploadResponseBody } from '/routes/api/files/upload.tsx';
import { RequestBody as RenameRequestBody, ResponseBody as RenameResponseBody } from '/routes/api/files/rename.tsx';
import { RequestBody as MoveRequestBody, ResponseBody as MoveResponseBody } from '/routes/api/files/move.tsx';
@ -49,6 +50,8 @@ interface MainFilesProps {
baseUrl: string;
isFileSharingAllowed: boolean;
fileShareId?: string;
initialSortBy?: SortColumn;
initialSortOrder?: SortOrder;
}
export default function MainFiles(
@ -59,6 +62,8 @@ export default function MainFiles(
baseUrl,
isFileSharingAllowed,
fileShareId,
initialSortBy = 'name',
initialSortOrder = 'asc',
}: MainFilesProps,
) {
const isAdding = useSignal<boolean>(false);
@ -68,6 +73,8 @@ export default function MainFiles(
const directories = useSignal<Directory[]>(initialDirectories);
const files = useSignal<DirectoryFile[]>(initialFiles);
const path = useSignal<string>(initialPath);
const sortBy = useSignal<SortColumn>(initialSortBy);
const sortOrder = useSignal<SortOrder>(initialSortOrder);
const chosenDirectories = useSignal<Pick<Directory, 'parent_path' | 'directory_name'>[]>([]);
const chosenFiles = useSignal<Pick<DirectoryFile, 'parent_path' | 'file_name'>[]>([]);
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(
</section>
<section class='flex items-center justify-end'>
<FilesBreadcrumb path={path.value} fileShareId={fileShareId} />
<FilesBreadcrumb
path={path.value}
fileShareId={fileShareId}
sortBy={sortBy.value}
sortOrder={sortOrder.value}
/>
{!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}
/>
<span

View file

@ -1,4 +1,5 @@
import { Directory, DirectoryFile } from '/lib/types.ts';
import { SortColumn, SortOrder } from '/lib/utils/files.ts';
import MainFiles from '/components/files/MainFiles.tsx';
interface FilesWrapperProps {
@ -8,6 +9,8 @@ interface FilesWrapperProps {
baseUrl: string;
isFileSharingAllowed: boolean;
fileShareId?: string;
initialSortBy?: SortColumn;
initialSortOrder?: SortOrder;
}
// This wrapper is necessary because islands need to be the first frontend component, but they don't support functions as props, so the more complex logic needs to live in the component itself
@ -19,6 +22,8 @@ export default function FilesWrapper(
baseUrl,
isFileSharingAllowed,
fileShareId,
initialSortBy = 'name',
initialSortOrder = 'asc',
}: FilesWrapperProps,
) {
return (
@ -29,6 +34,8 @@ export default function FilesWrapper(
baseUrl={baseUrl}
isFileSharingAllowed={isFileSharingAllowed}
fileShareId={fileShareId}
initialSortBy={initialSortBy}
initialSortOrder={initialSortOrder}
/>
);
}

View file

@ -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<Directory[]> {
static async list(userId: string, path: string, sortOptions?: SortOptions): Promise<Directory[]> {
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<boolean> {
@ -167,7 +170,7 @@ export class DirectoryModel {
}
export class FileModel {
static async list(userId: string, path: string): Promise<DirectoryFile[]> {
static async list(userId: string, path: string, sortOptions?: SortOptions): Promise<DirectoryFile[]> {
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');
}
}

View file

@ -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;
}

View file

@ -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<Data, FreshContextState> = {
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

View file

@ -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<Data, FreshContextState> = {
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 };

View file

@ -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<Data, FreshContextState> = {
@ -35,9 +38,22 @@ export const handler: Handlers<Data, FreshContextState> = {
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<Data, FreshContextState> = {
currentPath,
baseUrl,
isFileSharingAllowed: isPublicFileSharingAllowed,
sortBy: finalSortBy,
sortOrder: finalSortOrder,
});
},
};
@ -60,6 +78,8 @@ export default function FilesPage({ data }: PageProps<Data, FreshContextState>)
initialPath={data.currentPath}
baseUrl={data.baseUrl}
isFileSharingAllowed={data.isFileSharingAllowed}
initialSortBy={data.sortBy}
initialSortOrder={data.sortOrder}
/>
</main>
);