From d587e9e367430f2473d40725142d926195abc552 Mon Sep 17 00:00:00 2001 From: ewsgit Date: Sat, 25 Apr 2026 17:43:53 +0100 Subject: [PATCH] Many files UI & experience improvements --- .vscode/settings.json | 3 +- applications/uk.ewsgit.files/backend/index.ts | 33 +- .../uk.ewsgit.files/electron/src/main.ts | 25 +- .../uk.ewsgit.files/electron/src/preload.js | 1 + applications/uk.ewsgit.files/web/App.tsx | 10 +- .../web/layout/Layout.module.scss | 4 + .../uk.ewsgit.files/web/layout/Layout.tsx | 5 +- .../layout/components/ActionBar/ActionBar.tsx | 12 +- .../ViewMessage/ViewMessage.module.scss | 20 ++ .../components/ViewMessage/ViewMessage.tsx | 24 ++ .../web/lib/filesystemInterface.ts | 51 ++- .../uk.ewsgit.files/web/pages/dir/View.tsx | 316 +++++++++++------- .../DetailsView/DetailsView.module.scss | 45 ++- .../components/DetailsView/DetailsView.tsx | 155 +++++---- .../components/GridView/GridView.module.scss | 28 ++ .../dir/components/GridView/GridView.tsx | 17 +- .../dir/{itemSelection.ts => itemClick.ts} | 3 +- .../web/pages/dir/itemContextMenu.ts | 7 + .../web/pages/dir/viewHistory.ts | 24 ++ .../uk.ewsgit.files/web/pages/dir/viewItem.ts | 3 + instance/src/systems/users.ts | 1 + uikit-solid/src/components/card/UKCard.tsx | 9 + uikit-solid/src/components/menu/UKMenu.tsx | 70 ++-- 23 files changed, 598 insertions(+), 268 deletions(-) create mode 100644 applications/uk.ewsgit.files/web/layout/components/ViewMessage/ViewMessage.module.scss create mode 100644 applications/uk.ewsgit.files/web/layout/components/ViewMessage/ViewMessage.tsx rename applications/uk.ewsgit.files/web/pages/dir/{itemSelection.ts => itemClick.ts} (93%) create mode 100644 applications/uk.ewsgit.files/web/pages/dir/itemContextMenu.ts create mode 100644 applications/uk.ewsgit.files/web/pages/dir/viewHistory.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 289cf1f..9a60fec 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -6,6 +6,7 @@ "ghostty", "Landmarker", "onlineworkspace", - "solidjs" + "solidjs", + "unmaximize" ] } \ No newline at end of file diff --git a/applications/uk.ewsgit.files/backend/index.ts b/applications/uk.ewsgit.files/backend/index.ts index 5572a19..38e8f9c 100644 --- a/applications/uk.ewsgit.files/backend/index.ts +++ b/applications/uk.ewsgit.files/backend/index.ts @@ -401,7 +401,7 @@ const router = t.router({ }), view: { getEntry: procedure - .input(z.object({ path: z.string(), thumbnailSize: z.number() })) + .input(z.object({ path: z.string(), thumbnailSize: z.number().optional() })) .output( z .object({ @@ -412,6 +412,9 @@ const router = t.router({ thumbnail: z.string().optional(), shared: z.boolean().optional(), size: z.number().optional(), + hidden: z.boolean().optional(), + createdAt: z.number().optional(), + modifiedAt: z.number().optional(), }), }) .or(z.object({ status: z.enum(["missing_permission", "invalid_path"]) })), @@ -439,18 +442,22 @@ const router = t.router({ // TODO: implement sharing first shared: false, size: itemStats.size, - thumbnail: itemStats.isFile() - ? instance.sys.filesystem.getFileType(opt.input.path) === "image" - ? await instance.sys.image.serveImage(opt.ctx.userId, resolvedPath, { - resize: { - fit: "cover", - position: "centre", - dimensions: { width: opt.input.thumbnailSize, height: opt.input.thumbnailSize }, - changeFormatTo: "webp", - }, - }) - : undefined - : undefined, + thumbnail: + opt.input.thumbnailSize !== undefined && itemStats.isFile() + ? instance.sys.filesystem.getFileType(opt.input.path) === "image" + ? await instance.sys.image.serveImage(opt.ctx.userId, resolvedPath, { + resize: { + fit: "cover", + position: "centre", + dimensions: { width: opt.input.thumbnailSize, height: opt.input.thumbnailSize }, + changeFormatTo: "webp", + }, + }) + : undefined + : undefined, + hidden: path.basename(opt.input.path).startsWith("."), + createdAt: itemStats.ctimeMs, + modifiedAt: itemStats.atimeMs, }, }; } catch (_) { diff --git a/applications/uk.ewsgit.files/electron/src/main.ts b/applications/uk.ewsgit.files/electron/src/main.ts index 9ffa58e..7ae89ed 100644 --- a/applications/uk.ewsgit.files/electron/src/main.ts +++ b/applications/uk.ewsgit.files/electron/src/main.ts @@ -1,6 +1,6 @@ -import path from "node:path"; -import { app, BrowserWindow, ipcMain, session } from "electron"; import { promises as fs } from "node:fs"; +import path from "node:path"; +import { app, BrowserWindow, ipcMain, session, shell } from "electron"; import sharp from "sharp"; async function createWindow() { @@ -28,7 +28,7 @@ async function createWindow() { } function getFileType(path: string) { - const extension = path.split(".").pop(); + const extension = path.split(".").pop()?.toLowerCase(); switch (extension) { case "avif": @@ -89,13 +89,13 @@ app.whenReady().then(async () => { try { const itemStats = await fs.lstat(resolvedPath); - let thumbnail: Buffer | undefined = undefined; + let thumbnail: Buffer | undefined; if (getFileType(p) === "image") { thumbnail = await new Promise((resolve) => { sharp(resolvedPath) .toFormat("webp") - .resize(thumbnailSize) + .resize(thumbnailSize, thumbnailSize) .toBuffer((_, buffer) => { resolve(buffer); }); @@ -110,7 +110,10 @@ app.whenReady().then(async () => { // TODO: implement sharing first shared: false, size: itemStats.size, - thumbnail: thumbnail ? `data:image/webp;base64,${thumbnail.toString("base64")}` : undefined, + thumbnail: thumbnail, + hidden: path.basename(p).startsWith("."), + createdAt: itemStats.ctimeMs, + modifiedAt: itemStats.atimeMs, }, }; } catch (_) { @@ -124,6 +127,16 @@ app.whenReady().then(async () => { return process.platform; }); + ipcMain.handle("files:open_in_default_application", async (_, [p]: [string]) => { + const errorMessage = await shell.openPath(p); + + if (errorMessage !== "") { + return { status: errorMessage }; + } + + return { status: "ok" }; + }); + app.on("activate", () => { if (BrowserWindow.getAllWindows().length === 0) { createWindow(); diff --git a/applications/uk.ewsgit.files/electron/src/preload.js b/applications/uk.ewsgit.files/electron/src/preload.js index 6b6d189..000cb5d 100644 --- a/applications/uk.ewsgit.files/electron/src/preload.js +++ b/applications/uk.ewsgit.files/electron/src/preload.js @@ -9,6 +9,7 @@ contextBridge.exposeInMainWorld("electronAPI", { }, files: { get_entry: (path, thumbnailSize) => ipcRenderer.invoke("files:get_entry", [path, thumbnailSize]), + open_in_default_application: (path) => ipcRenderer.invoke("files:open_in_default_application", [path]), }, }); diff --git a/applications/uk.ewsgit.files/web/App.tsx b/applications/uk.ewsgit.files/web/App.tsx index 609c844..bf3d9ff 100644 --- a/applications/uk.ewsgit.files/web/App.tsx +++ b/applications/uk.ewsgit.files/web/App.tsx @@ -18,6 +18,9 @@ interface UserPreferences { pinnedDirectories: string[]; viewType: "grid" | "details" | "gallery"; showPreview: boolean; + detailsViewIconSize: number; + gridViewIconSize: number; + showHidden: boolean; } interface ViewState { @@ -59,15 +62,18 @@ const App: Component = () => { const [ userPreferences, setUserPreferences ] = createStore({ showWelcome: true, homePath: "remote:/users/1/fs/", - pinnedDirectories: [ "~/home" ], + pinnedDirectories: [ "remote:/users/1/fs/" ], viewType: "details", showPreview: false, + detailsViewIconSize: 32, + gridViewIconSize: 128, + showHidden: false }); const [ viewState, setViewState ] = createStore({ viewItems: [], selectedItems: [], lastSelectionTime: -1, - viewId: 0 + viewId: 0, }); const [ taskStatus, setTaskStatus ] = createSignal([]) diff --git a/applications/uk.ewsgit.files/web/layout/Layout.module.scss b/applications/uk.ewsgit.files/web/layout/Layout.module.scss index a161679..2554910 100644 --- a/applications/uk.ewsgit.files/web/layout/Layout.module.scss +++ b/applications/uk.ewsgit.files/web/layout/Layout.module.scss @@ -5,6 +5,10 @@ width: 100%; height: 100%; overflow: hidden; + + > * * { + user-select: none; + } } .sidebar { diff --git a/applications/uk.ewsgit.files/web/layout/Layout.tsx b/applications/uk.ewsgit.files/web/layout/Layout.tsx index 22bdbe6..89b34e3 100644 --- a/applications/uk.ewsgit.files/web/layout/Layout.tsx +++ b/applications/uk.ewsgit.files/web/layout/Layout.tsx @@ -1,6 +1,7 @@ import DELETE_ICON from "@material-symbols/svg-700/outlined/delete.svg"; import FOLDER_ICON from "@material-symbols/svg-700/outlined/folder.svg"; import HOUSE_ICON from "@material-symbols/svg-700/outlined/house.svg"; +import HARD_DRIVE_ICON from "@material-symbols/svg-700/outlined/hard_drive.svg" import PERSON_ICON from "@material-symbols/svg-700/outlined/person.svg"; import UKSideBar from "@onlineworkspace/uikit-solid/src/components/sideBar/UKSideBar.tsx"; import {useNavigate} from "@solidjs/router"; @@ -73,8 +74,8 @@ const Layout: Component = (props) => { {type: "label", label: "Local"}, { type: "button", - icon: {type: "icon", value: FOLDER_ICON}, - label: "Local Root", + icon: {type: "icon", value: HARD_DRIVE_ICON}, + label: "Root (Local)", onClick() { navigate(`/app/uk.ewsgit.files/dir?path=local:/`); }, diff --git a/applications/uk.ewsgit.files/web/layout/components/ActionBar/ActionBar.tsx b/applications/uk.ewsgit.files/web/layout/components/ActionBar/ActionBar.tsx index 582288e..172191d 100644 --- a/applications/uk.ewsgit.files/web/layout/components/ActionBar/ActionBar.tsx +++ b/applications/uk.ewsgit.files/web/layout/components/ActionBar/ActionBar.tsx @@ -17,11 +17,13 @@ import UKText from "@onlineworkspace/uikit-solid/src/components/text/UKText.jsx" import useIsMobile from "@onlineworkspace/uikit-solid/src/core/useIsMobile.ts"; import {useSearchParams} from "@solidjs/router"; import browserPath from "path-browserify"; -import {type Component, createSignal, Show, useContext} from "solid-js"; +import {type Component, createEffect, createSignal, Show, useContext} from "solid-js"; import {AppContext} from "../../../appContext.ts"; import type {ViewItem} from "../../../pages/dir/viewItem.ts"; import styles from "./ActionBar.module.scss"; import clsx from "clsx" +import {canViewNavigateUp, viewNavigateUp} from "../../../pages/dir/viewHistory.ts"; +import type {UniformResourceLocator} from "../../../lib/filesystemInterface.ts"; const ActionBar: Component = () => { const isMobile = useIsMobile(); @@ -29,6 +31,11 @@ const ActionBar: Component = () => { const [ searchParams, setSearchParams ] = useSearchParams<{path: string}>(); const [ pathQuery, setPathQuery ] = createSignal(undefined); const [ showTextualPath, setShowTextualPath ] = createSignal(false); + const [ canNavigateUp, setCanNavigateUp ] = createSignal(false); + + createEffect(() => { + setCanNavigateUp(canViewNavigateUp((searchParams.path || "invalid:") as UniformResourceLocator)) + }) return (
@@ -38,9 +45,10 @@ const ActionBar: Component = () => { { - console.log("Implement this"); + viewNavigateUp((p) => setSearchParams({path: p}), (searchParams.path || "invalid:") as UniformResourceLocator) }} />
diff --git a/applications/uk.ewsgit.files/web/layout/components/ViewMessage/ViewMessage.module.scss b/applications/uk.ewsgit.files/web/layout/components/ViewMessage/ViewMessage.module.scss new file mode 100644 index 0000000..1eb1f50 --- /dev/null +++ b/applications/uk.ewsgit.files/web/layout/components/ViewMessage/ViewMessage.module.scss @@ -0,0 +1,20 @@ +.root { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 1rem; +} + +.contentContainer { + width: max-content; + max-width: 32rem; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 1rem; + padding: 1rem; +} diff --git a/applications/uk.ewsgit.files/web/layout/components/ViewMessage/ViewMessage.tsx b/applications/uk.ewsgit.files/web/layout/components/ViewMessage/ViewMessage.tsx new file mode 100644 index 0000000..691fca2 --- /dev/null +++ b/applications/uk.ewsgit.files/web/layout/components/ViewMessage/ViewMessage.tsx @@ -0,0 +1,24 @@ +import type {Component} from "solid-js"; +import styles from "./ViewMessage.module.scss" +import UKIcon from "@onlineworkspace/uikit-solid/src/components/icon/UKIcon.jsx"; +import UKText from "@onlineworkspace/uikit-solid/src/components/text/UKText.jsx"; +import UKDivider from "@onlineworkspace/uikit-solid/src/components/divider/UKDivider.jsx"; +import UKButtonGroup from "@onlineworkspace/uikit-solid/src/components/buttonGroup/UKButtonGroup.jsx"; + +const ViewMessage: Component<{title: string, icon: string, message: string, actions?: {color: "elevated" | "filled" | "tonal" | "outlined" | "standard", label: string, onClick(): void}[]}> = (props) => { + return
+
+ {props.icon} + {props.title} + + {props.message} +
+ {!!props.actions && + + {null} + + } +
+} + +export default ViewMessage diff --git a/applications/uk.ewsgit.files/web/lib/filesystemInterface.ts b/applications/uk.ewsgit.files/web/lib/filesystemInterface.ts index a3986c8..3e95cde 100644 --- a/applications/uk.ewsgit.files/web/lib/filesystemInterface.ts +++ b/applications/uk.ewsgit.files/web/lib/filesystemInterface.ts @@ -73,7 +73,21 @@ class FilesystemInterface { } } - async getViewEntry(url: UniformResourceLocator, viewType: "details" | "grid") { + getViewEntryBatchSize(url: UniformResourceLocator) { + const parsedUrl = this.urlToPath(url); + + if (parsedUrl.type === "invalid") { + return 0; + } + + if (parsedUrl.type === "local") { + return 4; + } else { + return 8; + } + } + + async getViewEntry(url: UniformResourceLocator, thumbnailSize?: number) { const parsedUrl = this.urlToPath(url); if (parsedUrl.type === "invalid") { @@ -82,21 +96,31 @@ class FilesystemInterface { if (parsedUrl.type === "local") { // @ts-ignore - const getEntryResponse = await window.electronAPI.files.get_entry(parsedUrl.path, viewType === "details" ? 32 : 128); + const getEntryResponse = await window.electronAPI.files.get_entry(parsedUrl.path, thumbnailSize); if (getEntryResponse.status === "ok") { + let thumbnail: string | undefined; + + if (getEntryResponse.data.thumbnail) { + // @ts-ignore + const blob = new Blob([getEntryResponse.data.thumbnail]); + + thumbnail = URL.createObjectURL(blob); + } + return { status: getEntryResponse.status, data: { ...getEntryResponse.data, path: `${parsedUrl.type}:${getEntryResponse.data.path}`, + thumbnail: thumbnail, }, }; } else { return { status: getEntryResponse.status }; } } else { - const getEntryResponse = await trpc.view.getEntry.query({ path: parsedUrl.path, thumbnailSize: viewType === "details" ? 32 : 128 }); + const getEntryResponse = await trpc.view.getEntry.query({ path: parsedUrl.path, thumbnailSize: thumbnailSize }); if (getEntryResponse.status === "ok") { return { @@ -120,6 +144,27 @@ class FilesystemInterface { return quota; } + async openInDefaultApplication(url: UniformResourceLocator) { + const parsedUrl = this.urlToPath(url); + + if (parsedUrl.type === "invalid") { + return { status: "invalid_path" as const }; + } + + if (parsedUrl.type === "local") { + // @ts-ignore + const openInDefaultApplicationResponse = await window.electronAPI.files.open_in_default_application(parsedUrl.path); + + if (openInDefaultApplicationResponse.status === "ok") { + return { status: openInDefaultApplicationResponse.status }; + } else { + return { status: openInDefaultApplicationResponse.status }; + } + } else { + alert("Cannot open remote files as no applications exist to open them yet."); + } + } + joinUrls(...urls: UniformResourceLocator[]) { const firstUrlType = this.urlToPath(urls[0]).type; diff --git a/applications/uk.ewsgit.files/web/pages/dir/View.tsx b/applications/uk.ewsgit.files/web/pages/dir/View.tsx index fbc1d8a..790224a 100644 --- a/applications/uk.ewsgit.files/web/pages/dir/View.tsx +++ b/applications/uk.ewsgit.files/web/pages/dir/View.tsx @@ -1,15 +1,15 @@ +import ERROR_ICON from "@material-symbols/svg-700/outlined/error.svg"; import {useSearchParams} from "@solidjs/router"; import {type Component, createEffect, createSignal, Match, Switch, useContext} from "solid-js"; import {createStore} from "solid-js/store"; +import type {Task} from "../../App.tsx"; import {AppContext} from "../../appContext.ts"; -import trpc from "../../lib/trpc.ts"; +import ViewMessage from "../../layout/components/ViewMessage/ViewMessage.tsx"; +import chunkArray from "../../lib/chunk.ts"; +import filesystemInterface, {type UniformResourceLocator} from "../../lib/filesystemInterface.ts"; import DetailsView from "./components/DetailsView/DetailsView.tsx"; import GridView from "./components/GridView/GridView.tsx"; import styles from "./View.module.scss"; -import filesystemInterface, {type UniformResourceLocator} from "../../lib/filesystemInterface.ts"; -import chunkArray from "../../lib/chunk.ts"; -import type {ViewItem} from "./viewItem.ts"; -import type {Task} from "../../App.tsx"; const View: Component = () => { const [ searchParams, setSearchParams ] = useSearchParams<{path?: UniformResourceLocator}>(); @@ -22,7 +22,15 @@ const View: Component = () => { origin: undefined, size: undefined, }); - const [ errorMessage, setErrorMessage ] = createSignal(undefined) + const [ lastClickTime, setLastClickTime ] = createSignal(-1); + const [ errorMessage, setErrorMessage ] = createSignal(undefined); + let navigationCounter = 0; + + createEffect(async () => { + searchParams.path; + + navigationCounter++ + }) createEffect(async () => { if (!searchParams.path) { @@ -30,10 +38,14 @@ const View: Component = () => { return; } - const newItems = await filesystemInterface.readDirectory(searchParams.path || "remote:/") + const currentNavigationCount = navigationCounter; + + const newItems = await filesystemInterface.readDirectory(searchParams.path || "remote:/"); + + if (currentNavigationCount !== navigationCounter) return; if (newItems.status === "ok") { - setErrorMessage(undefined) + setErrorMessage(undefined); appContext?.setViewState("selectedItems", []); appContext?.setViewState("lastSelectionTime", -1); @@ -44,167 +56,223 @@ const View: Component = () => { id: crypto.randomUUID(), max: newItems.items.length, current: 0, - message: `Fetched %c of %m items` - } + message: `Fetched %c of %m items`, + }; + + appContext?.setTasks((tasks) => [ ...tasks, task ]); - appContext?.setTasks(tasks => [ ...tasks, task ]) + const CHUNK_SIZE = filesystemInterface.getViewEntryBatchSize(searchParams.path); - const CHUNK_SIZE = 10 for (const itemPathGroup of chunkArray(newItems.items, CHUNK_SIZE)) { - console.log(itemPathGroup) + if (currentNavigationCount !== navigationCounter) { + appContext?.setTasks((tasks) => { + return tasks.map((t) => { + if (task.id === t.id) { + return task; + } + + return t; + }); + }) + return; + }; await new Promise(async (resolve) => { const itemGroupResponsePromises = []; for (const itemPath of itemPathGroup) { - itemGroupResponsePromises.push(filesystemInterface.getViewEntry(itemPath, appContext!.userPreferences.viewType === "details" ? "details" : "grid").then((viewEntry) => { - if (viewEntry.status === "ok") { - return viewEntry - } - })) + itemGroupResponsePromises.push( + filesystemInterface + .getViewEntry( + itemPath as UniformResourceLocator, + appContext!.userPreferences.viewType === "details" + ? appContext?.userPreferences.detailsViewIconSize + : appContext?.userPreferences.gridViewIconSize, + ) + .then((viewEntry) => { + if (viewEntry.status === "ok") { + return viewEntry; + } + }), + ); } - appContext?.setViewState("viewItems", [ ...appContext.viewState.viewItems, ...(await Promise.all(itemGroupResponsePromises)).map(ig => ig?.data || undefined).filter(ig => ig !== undefined) ]); + + const itemGroupResponseResolvedPromises = await Promise.all(itemGroupResponsePromises) + if (currentNavigationCount !== navigationCounter) { + appContext?.setTasks((tasks) => { + return tasks.map((t) => { + if (task.id === t.id) { + return task; + } + + return t; + }); + }) + + resolve(); + return; + } + appContext?.setViewState("viewItems", [ + ...appContext.viewState.viewItems, + ...(itemGroupResponseResolvedPromises).map((ig) => ig?.data || undefined).filter((ig) => ig !== undefined), + ]); + if (!(task.current + CHUNK_SIZE > task.max)) { - task.current += CHUNK_SIZE + task.current += CHUNK_SIZE; } else { - task.current = task.max + task.current = task.max; } - appContext?.setTasks(tasks => { - return tasks.map(t => { + + appContext?.setTasks((tasks) => { + return tasks.map((t) => { if (task.id === t.id) { - return task + return task; } - return t - }) - }) - - console.log(appContext?.tasks()) + return t; + }); + }); resolve(); - }) + }); } } else { - setErrorMessage(newItems.status) + setErrorMessage(newItems.status); } }); return ( <> - {errorMessage() ?
{errorMessage()}
: <> - {/** biome-ignore lint/a11y/noStaticElementInteractions: button functionality not required */} -
{ - if (e.currentTarget === e.target) appContext!.setViewState("selectedItems", []); - }} - onMouseDown={(downEvent) => { - document.body.style.userSelect = "none"; - setDragSelectRegion("origin", {x: downEvent.clientX, y: downEvent.clientY}); - setDragSelectRegion("transOrigin", {x: downEvent.clientX, y: downEvent.clientY}); + {errorMessage() ? ( + + ) : appContext?.viewState.viewItems.length === 0 ? ( + + ) : ( + <> + {/** biome-ignore lint/a11y/noStaticElementInteractions: button functionality not required */} +
{ + if (lastClickTime() > Date.now() - 250) { + setLastClickTime(-1); + + if (appContext?.viewState.selectedItems.length === 0) { + appContext!.setViewState( + "selectedItems", + appContext.viewState.viewItems.map((i) => i.path), + ); + } else { + appContext!.setViewState("selectedItems", []); + } + } - const currentTarget = downEvent.currentTarget as HTMLDivElement; - const currentTargetBounds = currentTarget.getBoundingClientRect(); - const selectableItems = currentTarget.querySelectorAll("[data-fs-item-path]"); + setLastClickTime(Date.now()); + document.body.style.userSelect = "none"; + setDragSelectRegion("origin", {x: downEvent.clientX, y: downEvent.clientY}); + setDragSelectRegion("transOrigin", {x: downEvent.clientX, y: downEvent.clientY}); - function mouseUp() { - document.body.style.userSelect = "unset"; + const currentTarget = downEvent.currentTarget as HTMLDivElement; + const currentTargetBounds = currentTarget.getBoundingClientRect(); + const selectableItems = currentTarget.querySelectorAll("[data-fs-item-path]"); - setDragSelectRegion("origin", undefined); - setDragSelectRegion("size", undefined); - setDragSelectRegion("transOrigin", undefined); + function mouseUp() { + document.body.style.userSelect = "unset"; - document.removeEventListener("mouseup", mouseUp); - document.removeEventListener("mousemove", mouseMove); - } + setDragSelectRegion("origin", undefined); + setDragSelectRegion("size", undefined); + setDragSelectRegion("transOrigin", undefined); - let itemInRegionCalculationTimeout: NodeJS.Timeout | undefined; + document.removeEventListener("mouseup", mouseUp); + document.removeEventListener("mousemove", mouseMove); + } - function itemInRegionCalculation() { - appContext?.setViewState("selectedItems", []); + let itemInRegionCalculationTimeout: NodeJS.Timeout | undefined; - for (const item of selectableItems) { - const boundingRect = item.getBoundingClientRect(); + function itemInRegionCalculation() { + appContext?.setViewState("selectedItems", []); - const tl1 = {x: dragSelectRegion.transOrigin?.x || 0, y: dragSelectRegion.transOrigin?.y || 0}; - const br1 = {x: tl1.x + (dragSelectRegion.size?.x || 0), y: tl1.y + (dragSelectRegion.size?.y || 0)}; + for (const item of selectableItems) { + const boundingRect = item.getBoundingClientRect(); - const tl2 = {x: boundingRect.left, y: boundingRect.top}; - const br2 = {x: boundingRect.right, y: boundingRect.bottom}; + const tl1 = {x: dragSelectRegion.transOrigin?.x || 0, y: dragSelectRegion.transOrigin?.y || 0}; + const br1 = {x: tl1.x + (dragSelectRegion.size?.x || 0), y: tl1.y + (dragSelectRegion.size?.y || 0)}; - if (tl1.x > br2.x || tl2.x > br1.x) continue; + const tl2 = {x: boundingRect.left, y: boundingRect.top}; + const br2 = {x: boundingRect.right, y: boundingRect.bottom}; - if (tl1.y > br2.y || tl2.y > br1.y) continue; + if (tl1.x > br2.x || tl2.x > br1.x) continue; - const itemPath = item.getAttribute("data-fs-item-path"); - if (!itemPath) return; - if (!appContext?.viewState.selectedItems.includes(itemPath)) { - appContext?.setViewState("selectedItems", [ ...appContext.viewState.selectedItems, itemPath ]); + if (tl1.y > br2.y || tl2.y > br1.y) continue; + + const itemPath = item.getAttribute("data-fs-item-path"); + if (!itemPath) return; + if (!appContext?.viewState.selectedItems.includes(itemPath)) { + appContext?.setViewState("selectedItems", [ ...appContext.viewState.selectedItems, itemPath ]); + } } } - } - function mouseMove(e: MouseEvent) { - if (!dragSelectRegion.origin) return; - if (!dragSelectRegion.transOrigin) return; + function mouseMove(e: MouseEvent) { + if (!dragSelectRegion.origin) return; + if (!dragSelectRegion.transOrigin) return; - const mouseX = Math.min(Math.max(e.clientX, currentTargetBounds.left), currentTargetBounds.right); - const mouseY = Math.min(Math.max(e.clientY, currentTargetBounds.top), currentTargetBounds.bottom); + const mouseX = Math.min(Math.max(e.clientX, currentTargetBounds.left), currentTargetBounds.right); + const mouseY = Math.min(Math.max(e.clientY, currentTargetBounds.top), currentTargetBounds.bottom); - let sizeX = 0; - let sizeY = 0; + let sizeX = 0; + let sizeY = 0; - sizeX = mouseX - dragSelectRegion.origin.x; - sizeY = mouseY - dragSelectRegion.origin.y; + sizeX = mouseX - dragSelectRegion.origin.x; + sizeY = mouseY - dragSelectRegion.origin.y; - if (mouseX < dragSelectRegion.origin.x) { - setDragSelectRegion("transOrigin", {x: mouseX, y: dragSelectRegion.transOrigin.y}); - sizeX = dragSelectRegion.origin.x - mouseX; - } + if (mouseX < dragSelectRegion.origin.x) { + setDragSelectRegion("transOrigin", {x: mouseX, y: dragSelectRegion.transOrigin.y}); + sizeX = dragSelectRegion.origin.x - mouseX; + } - if (mouseY < dragSelectRegion.origin.y) { - setDragSelectRegion("transOrigin", {x: dragSelectRegion.transOrigin.x, y: mouseY}); - sizeY = dragSelectRegion.origin.y - mouseY; - } + if (mouseY < dragSelectRegion.origin.y) { + setDragSelectRegion("transOrigin", {x: dragSelectRegion.transOrigin.x, y: mouseY}); + sizeY = dragSelectRegion.origin.y - mouseY; + } - setDragSelectRegion("size", { - x: sizeX, - y: sizeY, - }); + setDragSelectRegion("size", { + x: sizeX, + y: sizeY, + }); - if (itemInRegionCalculationTimeout) clearTimeout(itemInRegionCalculationTimeout); - - itemInRegionCalculationTimeout = setTimeout(() => { - itemInRegionCalculation(); - }, 2); - } - - document.addEventListener("mouseup", mouseUp); - document.addEventListener("mousemove", mouseMove); - }} - > - - - - - - - - Gallery View - - {dragSelectRegion.transOrigin !== undefined ? ( -
- ) : null} -
- - } + if (itemInRegionCalculationTimeout) clearTimeout(itemInRegionCalculationTimeout); + + itemInRegionCalculationTimeout = setTimeout(() => { + itemInRegionCalculation(); + }, 2); + } + + document.addEventListener("mouseup", mouseUp); + document.addEventListener("mousemove", mouseMove); + }} + > + + + + + + + + Gallery View + + {dragSelectRegion.transOrigin !== undefined ? ( +
+ ) : null} +
+ + )} ); }; diff --git a/applications/uk.ewsgit.files/web/pages/dir/components/DetailsView/DetailsView.module.scss b/applications/uk.ewsgit.files/web/pages/dir/components/DetailsView/DetailsView.module.scss index 111940b..9a5c974 100644 --- a/applications/uk.ewsgit.files/web/pages/dir/components/DetailsView/DetailsView.module.scss +++ b/applications/uk.ewsgit.files/web/pages/dir/components/DetailsView/DetailsView.module.scss @@ -7,7 +7,8 @@ } .root { - --item-size: 2rem; + --icon-size: 2rem; + --item-height: calc(var(--icon-size) + 1rem); --col-spacing: 0.5rem; // display: grid; @@ -28,12 +29,16 @@ // gap: 5rem; padding-left: var(--col-spacing); padding-right: var(--col-spacing); - padding-top: 0.25rem; padding-bottom: 0.25rem; &:first-child { padding-left: 0; padding-right: 0; + width: 0; + } + + &:nth-child(2) { + width: 50%; } } } @@ -49,11 +54,14 @@ // justify-content: start; // border: none; position: relative; + animation: forwards fadeItemIn 200ms var(--uk-sys-motion-easing-standard-decelerate); > td { - height: var(--item-size); padding-left: var(--col-spacing); padding-right: var(--col-spacing); + height: var(--item-height); + box-sizing: border-box; + vertical-align: middle; } &::after { @@ -65,6 +73,12 @@ height: 100%; background: transparent; border-radius: 0.5rem; + pointer-events: none; + } + + &.itemHidden { + opacity: 0.6; + animation: forwards fadeHiddenItemIn 200ms var(--uk-sys-motion-easing-standard-decelerate); } &:nth-child(even) { @@ -80,8 +94,7 @@ } > td:first-child { - --icon-size: 1.5rem; - padding-left: calc(calc(var(--item-size) - var(--icon-size)) / 2); + padding-left: 0.5rem; border-radius: 0.5rem 0 0 0.5rem; } @@ -90,12 +103,32 @@ } .itemThumbnail { - height: var(--item-size); + height: var(--icon-size, 24px); aspect-ratio: 1 / 1; object-fit: cover; object-position: center; + display: block; } .itemIcon { + display: block; + } +} + +@keyframes fadeItemIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes fadeHiddenItemIn { + from { + opacity: 0; + } + to { + opacity: 0.6; } } diff --git a/applications/uk.ewsgit.files/web/pages/dir/components/DetailsView/DetailsView.tsx b/applications/uk.ewsgit.files/web/pages/dir/components/DetailsView/DetailsView.tsx index f98647d..7a83c3e 100644 --- a/applications/uk.ewsgit.files/web/pages/dir/components/DetailsView/DetailsView.tsx +++ b/applications/uk.ewsgit.files/web/pages/dir/components/DetailsView/DetailsView.tsx @@ -4,86 +4,101 @@ import browserPath from "path-browserify"; import {type Component, For, useContext} from "solid-js"; import {AppContext} from "../../../../appContext.ts"; import iconForItemType from "../../iconForItemType.ts"; -import onItemClick from "../../itemSelection.ts"; +import onItemClick from "../../itemClick.ts"; import styles from "./DetailsView.module.scss"; import type {DOMElement} from "solid-js/jsx-runtime"; import {useSearchParams} from "@solidjs/router"; import humanReadableSize from "../../../../lib/humanReadableSize.ts"; +import clsx from "clsx"; const DetailsView: Component = () => { const [ _, setSearchParams ] = useSearchParams(); const appContext = useContext(AppContext); return ( - <> - {appContext?.viewState.viewItems.length === 0 ? ( -
You have no files
- ) : ( - - - - - - - - - - - {(item, index) => { - return ( - - onItemClick(e as unknown as MouseEvent & {currentTarget: HTMLButtonElement; target: DOMElement}, appContext!, index(), item, setSearchParams) - } - data-selected={appContext?.viewState.selectedItems.includes(item.path)} - > - - - - - - - ); - }} - - -
- - Name - - - - Date Modified - - - - Time Modified - - - - Size - -
{item.thumbnail !== undefined ? : {iconForItemType(item.type)}} - - {browserPath.basename(item.path)} - - - - 1/1/1970 - - - - 00:23 - - - - {humanReadableSize(item.size || 0)} - -
- )} - + + + + + + + + + + + + + {(item, index) => { + if (!appContext?.userPreferences.showHidden && item.hidden) return null; + + let modifiedAtString = (new Date(item.modifiedAt || 0)).toLocaleString() + const createdAtString = (new Date(item.createdAt || 0)).toLocaleString() + + if (modifiedAtString === createdAtString) { + modifiedAtString = "-" + } + + return ( + { + e.stopPropagation(); + onItemClick(e as unknown as MouseEvent & {currentTarget: HTMLButtonElement; target: DOMElement}, appContext!, index(), item, setSearchParams) + }} + onMouseDown={(e) => { + e.preventDefault() + e.stopPropagation() + }} + onDblClick={(e) => { + e.preventDefault() + e.stopPropagation() + }} + data-selected={appContext?.viewState.selectedItems.includes(item.path)} + > + + + + + + + ); + }} + + +
+ + Name + + + + Modified + + + + Created + + + + Size + +
{item.thumbnail !== undefined ? : {iconForItemType(item.type)}} + + {browserPath.basename(item.path)} + + + + {modifiedAtString} + + + + {createdAtString} + + + + {humanReadableSize(item.size || 0)} + +
); }; diff --git a/applications/uk.ewsgit.files/web/pages/dir/components/GridView/GridView.module.scss b/applications/uk.ewsgit.files/web/pages/dir/components/GridView/GridView.module.scss index fbf0468..bfe6ae1 100644 --- a/applications/uk.ewsgit.files/web/pages/dir/components/GridView/GridView.module.scss +++ b/applications/uk.ewsgit.files/web/pages/dir/components/GridView/GridView.module.scss @@ -14,11 +14,17 @@ gap: 0.5rem; align-items: center; justify-content: center; + animation: forwards fadeItemIn 200ms var(--uk-sys-motion-easing-standard-decelerate); .thumbnailIcon { --icon-size: 6rem; } + &.itemHidden { + opacity: 0.6; + animation: forwards fadeHiddenItemIn 200ms var(--uk-sys-motion-easing-standard-decelerate); + } + .itemThumbnail { height: 128px; aspect-ratio: 1 / 1; @@ -26,3 +32,25 @@ object-position: center; } } + +@keyframes fadeItemIn { + from { + opacity: 0; + scale: 0.9; + } + to { + opacity: 1; + scale: 1; + } +} + +@keyframes fadeHiddenItemIn { + from { + opacity: 0; + scale: 0.9; + } + to { + opacity: 0.6; + scale: 1; + } +} diff --git a/applications/uk.ewsgit.files/web/pages/dir/components/GridView/GridView.tsx b/applications/uk.ewsgit.files/web/pages/dir/components/GridView/GridView.tsx index 1c3f283..7090ee5 100644 --- a/applications/uk.ewsgit.files/web/pages/dir/components/GridView/GridView.tsx +++ b/applications/uk.ewsgit.files/web/pages/dir/components/GridView/GridView.tsx @@ -4,9 +4,10 @@ import browserPath from "path-browserify"; import {type Component, For, useContext} from "solid-js"; import {AppContext} from "../../../../appContext.ts"; import iconForItemType from "../../iconForItemType.ts"; -import onItemClick from "../../itemSelection.ts"; +import onItemClick from "../../itemClick.ts"; import styles from "./GridView.module.scss"; import {useSearchParams} from "@solidjs/router"; +import clsx from "clsx"; const GridView: Component = () => { const [ _, setSearchParams ] = useSearchParams(); @@ -16,11 +17,21 @@ const GridView: Component = () => {
{(item, index) => { + if (!appContext?.userPreferences.showHidden && item.hidden) return null; + return ( onItemClick(e, appContext!, index(), item, setSearchParams)} + onMouseDown={(e) => { + e.preventDefault() + e.stopPropagation() + }} + onDblClick={(e) => { + e.preventDefault() + e.stopPropagation() + }} color={appContext?.viewState.selectedItems.includes(item.path) ? "outlined" : "filled"} > {item.thumbnail !== undefined ? : {iconForItemType(item.type)}} @@ -29,7 +40,7 @@ const GridView: Component = () => { ); }} -
+
); }; diff --git a/applications/uk.ewsgit.files/web/pages/dir/itemSelection.ts b/applications/uk.ewsgit.files/web/pages/dir/itemClick.ts similarity index 93% rename from applications/uk.ewsgit.files/web/pages/dir/itemSelection.ts rename to applications/uk.ewsgit.files/web/pages/dir/itemClick.ts index abfe90e..0ae2e7c 100644 --- a/applications/uk.ewsgit.files/web/pages/dir/itemSelection.ts +++ b/applications/uk.ewsgit.files/web/pages/dir/itemClick.ts @@ -1,6 +1,7 @@ import type { DOMElement } from "solid-js/jsx-runtime"; import type { AppContextType } from "../../App.tsx"; import type { ViewItem } from "./viewItem.ts"; +import filesystemInterface, { type UniformResourceLocator } from "../../lib/filesystemInterface.ts"; const onItemClick = ( e: MouseEvent & { currentTarget: HTMLButtonElement; target: DOMElement }, @@ -67,7 +68,7 @@ const onItemClick = ( if (appContext?.viewState.selectedItems.length === 1 && appContext.viewState.selectedItems[0] === item.path) { if (appContext.viewState.lastSelectionTime > Date.now() - 500) { if (item.type === "file") { - alert("Implement this when we can open items!"); + filesystemInterface.openInDefaultApplication(item.path as UniformResourceLocator); return; } diff --git a/applications/uk.ewsgit.files/web/pages/dir/itemContextMenu.ts b/applications/uk.ewsgit.files/web/pages/dir/itemContextMenu.ts new file mode 100644 index 0000000..2425718 --- /dev/null +++ b/applications/uk.ewsgit.files/web/pages/dir/itemContextMenu.ts @@ -0,0 +1,7 @@ +import type { MenuItem } from "@onlineworkspace/uikit-solid/src/components/menu/UKMenu.jsx"; +import type { AppContextType } from "../../App"; +import type { ViewItem } from "./viewItem"; + +const itemContextMenu = (appContext: AppContextType, itemIndex: number, item: ViewItem): (MenuItem | undefined)[] => { + return []; +}; diff --git a/applications/uk.ewsgit.files/web/pages/dir/viewHistory.ts b/applications/uk.ewsgit.files/web/pages/dir/viewHistory.ts new file mode 100644 index 0000000..e2e065d --- /dev/null +++ b/applications/uk.ewsgit.files/web/pages/dir/viewHistory.ts @@ -0,0 +1,24 @@ +import path from "path-browserify"; +import type { UniformResourceLocator } from "../../lib/filesystemInterface"; +import filesystemInterface from "../../lib/filesystemInterface"; + +export function canViewNavigateUp(url: UniformResourceLocator) { + const parsedUrl = filesystemInterface.urlToPath(url); + + if (parsedUrl.type === "invalid") return false; + + const prePath = parsedUrl.path; + const newPath = path.join(parsedUrl.path, ".."); + + if (prePath === newPath) return false; + + return true; +} + +export function viewNavigateUp(setPath: (path: string) => void, url: UniformResourceLocator) { + const parsedUrl = filesystemInterface.urlToPath(url); + + if (parsedUrl.type === "invalid") return; + + setPath(`${parsedUrl.type}:${path.join(parsedUrl.path, "..")}`); +} diff --git a/applications/uk.ewsgit.files/web/pages/dir/viewItem.ts b/applications/uk.ewsgit.files/web/pages/dir/viewItem.ts index 787e2cb..e0b6741 100644 --- a/applications/uk.ewsgit.files/web/pages/dir/viewItem.ts +++ b/applications/uk.ewsgit.files/web/pages/dir/viewItem.ts @@ -4,6 +4,9 @@ type ViewItem = { thumbnail?: string; shared?: boolean; size?: number; + hidden?: boolean; + createdAt?: number; + modifiedAt?: number; }; export type { ViewItem }; diff --git a/instance/src/systems/users.ts b/instance/src/systems/users.ts index f80df94..7438847 100644 --- a/instance/src/systems/users.ts +++ b/instance/src/systems/users.ts @@ -409,6 +409,7 @@ export class WorkspacesUser { "/assets/avatar", "/assets/wallpapers", "/assets/wallpapers/resized", + "/mounts", "/system", "/system/logs", "/system/temp", diff --git a/uikit-solid/src/components/card/UKCard.tsx b/uikit-solid/src/components/card/UKCard.tsx index a3de468..fcc7fdd 100644 --- a/uikit-solid/src/components/card/UKCard.tsx +++ b/uikit-solid/src/components/card/UKCard.tsx @@ -9,6 +9,8 @@ const UKCard: Component< color?: CardColor; hashId?: string; onClick?: JSX.EventHandler; + onMouseDown?: JSX.EventHandler; + onDblClick?: JSX.EventHandler; [key: `data-${string}`]: string; }> > = (props) => { @@ -22,17 +24,24 @@ const UKCard: Component< data-color={props.color || "filled"} class={clsx(styles.root, props.class)} data-clickable={!!props.onClick} + onMouseDown={props.onMouseDown} + onDblClick={props.onDblClick} > {props.children} + {/** biome-ignore lint/a11y/noStaticElementInteractions: TODO: put an explanation here sometime */}
)} id={props.hashId} data-color={props.color || "filled"} class={clsx(styles.root, props.class)} data-clickable={!!props.onClick} + // @ts-ignore + onMouseDown={props.onMouseDown} + // @ts-ignore + onDblClick={props.onDblClick} > {props.children}
diff --git a/uikit-solid/src/components/menu/UKMenu.tsx b/uikit-solid/src/components/menu/UKMenu.tsx index feedcaf..2fb134f 100644 --- a/uikit-solid/src/components/menu/UKMenu.tsx +++ b/uikit-solid/src/components/menu/UKMenu.tsx @@ -1,48 +1,48 @@ import clsx from "clsx"; -import { type Accessor, type Component, createSignal, For } from "solid-js"; -import { Portal } from "solid-js/web"; +import {type Accessor, type Component, createSignal, For} from "solid-js"; +import {Portal} from "solid-js/web"; import UKDivider from "../divider/UKDivider.tsx"; import UKIcon from "../icon/UKIcon.tsx"; import UKText from "../text/UKText.tsx"; import styles from "./UKMenu.module.scss"; -type MenuItem = +export type MenuItem = | { - type: "button"; - leadingIcon?: string; - label?: string; - supportingText?: string; - badge?: string; - onClick(): void | Promise; - selected?: boolean; - disabled?: boolean; - } + type: "button"; + leadingIcon?: string; + label?: string; + supportingText?: string; + badge?: string; + onClick(): void | Promise; + selected?: boolean; + disabled?: boolean; + } | { - type: "category"; - leadingIcon?: string; - supportingText?: string; - label?: string; - badge?: string; - children?: MenuItem[]; - onClick?(): void; - selected?: boolean; - disabled?: boolean; - } + type: "category"; + leadingIcon?: string; + supportingText?: string; + label?: string; + badge?: string; + children?: MenuItem[]; + onClick?(): void; + selected?: boolean; + disabled?: boolean; + } | { - type: "divider"; - } + type: "divider"; + } | { - type: "spacer"; - }; + type: "spacer"; + }; const UKMenu: Component<{ items: (MenuItem | undefined)[]; class?: string; - showMenu: Accessor<{ x: number; y: number } | false>; + showMenu: Accessor<{x: number; y: number} | false>; closeMenu(): void; vibrant?: boolean; }> = (props) => { - const [ref, setRef] = createSignal(); + const [ ref, setRef ] = createSignal(); return ( <> @@ -53,11 +53,11 @@ const UKMenu: Component<{ items={(props.items[selected() as number] as { children: MenuItem[] }).children || []} /> )}*/} -
+
{props.showMenu() !== false && ( - {/** biome-ignore lint/a11y/useKeyWithClickEvents: */} - {/** biome-ignore lint/a11y/noStaticElementInteractions: */} + {/** biome-ignore lint/a11y/useKeyWithClickEvents: TODO: create an explanation for this */} + {/** biome-ignore lint/a11y/noStaticElementInteractions: TODO: create an explanation for this */}
{ @@ -88,16 +88,16 @@ const UKMenu: Component<{ e.stopPropagation(); }} >
- {/** biome-ignore lint/a11y/useKeyWithClickEvents: */} - {/** biome-ignore lint/a11y/noStaticElementInteractions: */} + {/** biome-ignore lint/a11y/useKeyWithClickEvents: TODO: create an explanation for this */} + {/** biome-ignore lint/a11y/noStaticElementInteractions: TODO: create an explanation for this */}
e.preventDefault()} onClick={() => props.closeMenu()} class={clsx(styles.root, props.class)} style={{ - top: `${(props.showMenu() as { y: number }).y}px`, - left: `${(props.showMenu() as { x: number }).x}px`, + top: `${(props.showMenu() as {y: number}).y}px`, + left: `${(props.showMenu() as {x: number}).x}px`, }} > -- 2.51.2