diff --git a/applications/uk.ewsgit.files/backend/index.ts b/applications/uk.ewsgit.files/backend/index.ts index 0eb31b4..5ed5999 100644 --- a/applications/uk.ewsgit.files/backend/index.ts +++ b/applications/uk.ewsgit.files/backend/index.ts @@ -379,7 +379,7 @@ const router = t.router({ const userPermissions = await instance.sys.filesystem.getUserPermissions(opt.ctx.userId, resolvedPath); if (!userPermissions.read) { - return { status: "missing_permission" }; + return { status: "missing_permission" as const }; } try { @@ -394,7 +394,7 @@ const router = t.router({ return { items: directoryContents.map((ent) => ent.name), status: "ok" }; } catch (_) { - return { status: "invalid_path" }; + return { status: "invalid_path" as const }; } }), view: { @@ -425,7 +425,7 @@ const router = t.router({ if (!userPermissions.read) { return { - status: "missing_permission", + status: "missing_permission" as const, }; } @@ -433,7 +433,7 @@ const router = t.router({ const itemStats = await fs.lstat(resolvedPath); return { - status: "ok", + status: "ok" as const, data: { path: opt.input.path, type: itemStats.isSymbolicLink() ? "link" : itemStats.isDirectory() ? "directory" : itemStats.isFile() ? "file" : "file", @@ -460,7 +460,7 @@ const router = t.router({ }; } catch (_) { return { - status: "invalid_path", + status: "invalid_path" as const, }; } }), @@ -503,6 +503,44 @@ const router = t.router({ return resp; }), + previewDialog: { + get: procedure.input(z.object({ path: z.string() })).query(async (opt) => { + const resolvedPath = path.join(instance.sys.filesystem.FS_ROOT, opt.input.path); + + const pathStat = await fs.lstat(resolvedPath); + const isDirectory = pathStat.isDirectory(); + + const fileType = instance.sys.filesystem.getFileType(resolvedPath); + + let imageDimensions: { width: number; height: number } = { width: 0, height: 0 }; + + return { + status: "ok" as const, + data: { + assets: + fileType === "image" + ? { + original: await instance.sys.image.serveImage(opt.ctx.userId, resolvedPath), + small: await instance.sys.image.serveImage(opt.ctx.userId, resolvedPath, { + resize: { + dimensions: (original) => { + imageDimensions = original; + + return { width: 1024, height: Math.floor((original.height / original.width) * 1024) }; + }, + }, + }), + } + : undefined, + metadata: { + size: pathStat.size, + itemCount: isDirectory ? (await fs.readdir(resolvedPath)).length : undefined, + pixelate: imageDimensions.width !== 0 && imageDimensions.height !== 0 && imageDimensions.width < 640 && imageDimensions.height < 640, + }, + }, + }; + }), + }, }); export type TRPCRouter = typeof router; diff --git a/applications/uk.ewsgit.files/web/App.tsx b/applications/uk.ewsgit.files/web/App.tsx index 11ca2ca..31a6c74 100644 --- a/applications/uk.ewsgit.files/web/App.tsx +++ b/applications/uk.ewsgit.files/web/App.tsx @@ -26,13 +26,15 @@ interface ViewState { viewItems: ViewItem[]; selectedItems: string[]; lastSelectionTime: number; + lastSelectedItem: string | undefined; viewId: number; isLoading: boolean; - isRenaming: boolean; + isRenaming: string | undefined; } interface GlobalState { - showPreview: string | undefined; + showPreview: boolean; + disableShortcuts: boolean; } interface AppContextType { @@ -80,12 +82,14 @@ const App: Component = () => { viewItems: [], selectedItems: [], lastSelectionTime: -1, + lastSelectedItem: undefined, viewId: 0, isLoading: true, - isRenaming: false + isRenaming: undefined }); const [ globalState, setGlobalState ] = createStore({ - showPreview: undefined + showPreview: false, + disableShortcuts: false, }) const [ taskStatus, setTaskStatus ] = createSignal([]) diff --git a/applications/uk.ewsgit.files/web/layout/Layout.tsx b/applications/uk.ewsgit.files/web/layout/Layout.tsx index 2d10df0..9a00211 100644 --- a/applications/uk.ewsgit.files/web/layout/Layout.tsx +++ b/applications/uk.ewsgit.files/web/layout/Layout.tsx @@ -13,6 +13,7 @@ import PreviewPane from "./components/PreviewPane/PreviewPane.tsx"; import Quota from "./components/Quota/Quota.tsx"; import StatusBar from "./components/StatusBar/StatusBar.tsx"; import styles from "./Layout.module.scss"; +import type {UniformResourceLocator} from "../lib/filesystemInterface.ts"; const Layout: Component = (props) => { const navigate = useNavigate(); @@ -120,8 +121,8 @@ const Layout: Component = (props) => { - - + + ); diff --git a/applications/uk.ewsgit.files/web/layout/components/PreviewDialog/PreviewDialog.module.scss b/applications/uk.ewsgit.files/web/layout/components/PreviewDialog/PreviewDialog.module.scss index 693ef4b..690b612 100644 --- a/applications/uk.ewsgit.files/web/layout/components/PreviewDialog/PreviewDialog.module.scss +++ b/applications/uk.ewsgit.files/web/layout/components/PreviewDialog/PreviewDialog.module.scss @@ -11,7 +11,6 @@ .component { position: fixed; - top: 50%; left: 50%; transform: translate(-50%, -50%); @@ -21,6 +20,23 @@ flex-direction: column; z-index: 10; animation: animateIn forwards 200ms ease-in-out; + interpolate-size: allow-keywords; + transition: + max-width var(--uk-sys-motion-duration-500) var(--uk-sys-motion-easing-emphasized-normal), + height var(--uk-sys-motion-duration-500) var(--uk-sys-motion-easing-emphasized-normal); + + max-width: 1024px; + max-height: calc(100% - 2rem); + height: max-content; + width: calc(100% - 2rem); + + &.expanded { + max-width: 100%; + } + + &.loadingData { + height: 256px; + } } @keyframes animateIn { @@ -37,3 +53,51 @@ flex-direction: row; gap: 0.5rem; } + +.title { + margin-right: auto; + user-select: none; +} + +.previewContentContainer { + width: 100%; + height: 100%; + display: flex; + overflow: hidden; + position: relative; + + > img { + object-fit: contain; + object-position: center; + + max-width: 100%; + max-height: 100%; + width: 100%; + } +} + +.pixelate { + > img { + image-rendering: pixelated; + } +} + +.originalPreview { + position: absolute; + top: 0; + left: 0; + max-width: max-content; + height: 100%; + border-radius: calc(var(--uk-sys-shape-corner-medium) - 0.5rem); + transition: opacity var(--uk-sys-motion-duration-500) var(--uk-sys-motion-easing-emphasized-decelerate); + opacity: 0; + z-index: 2; +} + +.smallPreview { + border-radius: calc(var(--uk-sys-shape-corner-medium) - 0.5rem); +} + +.originalHasLoaded { + opacity: 1; +} diff --git a/applications/uk.ewsgit.files/web/layout/components/PreviewDialog/PreviewDialog.tsx b/applications/uk.ewsgit.files/web/layout/components/PreviewDialog/PreviewDialog.tsx index 7c91f51..ca88f79 100644 --- a/applications/uk.ewsgit.files/web/layout/components/PreviewDialog/PreviewDialog.tsx +++ b/applications/uk.ewsgit.files/web/layout/components/PreviewDialog/PreviewDialog.tsx @@ -1,39 +1,103 @@ import CLOSE_ICON from "@material-symbols/svg-700/outlined/close.svg"; +import COLLAPSE_CONTENT_ICON from "@material-symbols/svg-700/outlined/collapse_content.svg" +import EXPAND_CONTENT_ICON from "@material-symbols/svg-700/outlined/expand_content.svg"; +import ROTATE_90_DEGREES_CW_ICON from "@material-symbols/svg-700/outlined/rotate_90_degrees_cw.svg"; +import SHARE_ICON from "@material-symbols/svg-700/outlined/share.svg"; +import UKButton from "@onlineworkspace/uikit-solid/src/components/button/UKButton.jsx"; import UKCard from "@onlineworkspace/uikit-solid/src/components/card/UKCard.jsx"; import UKIconButton from "@onlineworkspace/uikit-solid/src/components/iconButton/UKIconButton.jsx"; import UKText from "@onlineworkspace/uikit-solid/src/components/text/UKText.jsx"; import path from "path-browserify"; -import {useContext, type Component} from "solid-js"; -import styles from "./PreviewDialog.module.scss"; +import {type Component, createResource, createSignal, Show, Suspense, useContext} from "solid-js"; import {AppContext} from "../../../appContext"; +import styles from "./PreviewDialog.module.scss"; +import clsx from "clsx"; +import filesystemInterface, {type UniformResourceLocator} from "../../../lib/filesystemInterface"; -const PreviewDialog: Component<{path: string}> = (props) => { +const PreviewDialog: Component<{pathUrl: UniformResourceLocator}> = (props) => { const appContext = useContext(AppContext); + const [ isExpanded, setIsExpanded ] = createSignal(false); + const [ originalHasLoaded, setOriginalHasLoaded ] = createSignal(false); + const [ data ] = createResource(() => props.pathUrl, (url) => filesystemInterface.getPreviewDialogMetadata(url as UniformResourceLocator)) return ( <> -