diff --git a/Dockerfile b/Dockerfile index 365b9d2..5992f13 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,6 +25,8 @@ RUN mkdir built && \ FROM node:16.14.2-alpine +ENV NODE_ENV=production + COPY --from=client-builder --chown=node:node /usr/src/app/picture-gallery-client/built /usr/src/app/picture-gallery-client/ COPY --from=server-builder --chown=node:node /usr/src/app/picture-gallery-server/built /usr/src/app/picture-gallery-server/ diff --git a/picture-gallery-client/src/ImageGallery/FolderGallery.tsx b/picture-gallery-client/src/ImageGallery/FolderGallery.tsx new file mode 100644 index 0000000..bff407a --- /dev/null +++ b/picture-gallery-client/src/ImageGallery/FolderGallery.tsx @@ -0,0 +1,49 @@ +import React from "react"; +import { FolderPreview } from "./models"; +import { useColumns } from "../util/responsive"; +import { + Chip, + ImageList, + ImageListItem, + ImageListItemBar, +} from "@mui/material"; +import { Link } from "react-router-dom"; + +export const FolderGallery = ({ folders }: { folders: FolderPreview[] }) => { + const columns = useColumns(); + + return ( + + {folders.map((folder) => ( + + {/* Link and image styling taken from https://github.com/mui/material-ui/issues/22597 */} + + {folder.name} + + } + /> + + + ))} + + ); +}; diff --git a/picture-gallery-client/src/ImageGallery/ImageGallery.tsx b/picture-gallery-client/src/ImageGallery/ImageGallery.tsx index c925ae2..2eb838a 100644 --- a/picture-gallery-client/src/ImageGallery/ImageGallery.tsx +++ b/picture-gallery-client/src/ImageGallery/ImageGallery.tsx @@ -9,21 +9,15 @@ import Thumbnails from "yet-another-react-lightbox/plugins/thumbnails"; import "yet-another-react-lightbox/plugins/thumbnails.css"; import Zoom from "yet-another-react-lightbox/plugins/zoom"; import { ImageList, ImageListItem } from "@mui/material"; +import { useColumns } from "../util/responsive"; -function ImageGallery({ images }: { images: ImageWithThumbnail[] }) { +export const ImageGallery = ({ images }: { images: ImageWithThumbnail[] }) => { const [index, setIndex] = useState(-1); - - if (images.length === 0) { - return ( -

- No images available. You may want to add images in your root directory. -

- ); - } + const columns = useColumns(); return ( <> - + {images.map((item, index) => ( ); -} - -export default ImageGallery; +}; diff --git a/picture-gallery-client/src/ImageGallery/models.ts b/picture-gallery-client/src/ImageGallery/models.ts index 09d8e09..5921a09 100644 --- a/picture-gallery-client/src/ImageGallery/models.ts +++ b/picture-gallery-client/src/ImageGallery/models.ts @@ -7,6 +7,13 @@ export interface Folders { children: Folders[]; } +export interface FolderPreview { + name: string; + fullPath: string; + numberOfFiles: number; + imagePreviewSrc: string | undefined; +} + export interface ImageWithThumbnail extends Slide { thumbnail: string; } diff --git a/picture-gallery-client/src/ImageGalleryLayout.tsx b/picture-gallery-client/src/ImageGalleryLayout.tsx index ad23cde..e195afd 100644 --- a/picture-gallery-client/src/ImageGalleryLayout.tsx +++ b/picture-gallery-client/src/ImageGalleryLayout.tsx @@ -2,12 +2,18 @@ import React, { useEffect, useState } from "react"; import CssBaseline from "@mui/material/CssBaseline"; import Box from "@mui/material/Box"; import { useLocation, useNavigate } from "react-router-dom"; -import { Folders, ImageWithThumbnail } from "./ImageGallery/models"; +import { + FolderPreview, + Folders, + ImageWithThumbnail, +} from "./ImageGallery/models"; import { ImageGalleryAppBar } from "./ImageGallery/ImageGalleryAppBar"; import { ImageGalleryDrawer } from "./ImageGallery/ImageGalleryDrawer"; -import ImageGallery from "./ImageGallery/ImageGallery"; +import { ImageGallery } from "./ImageGallery/ImageGallery"; import { Spinner } from "./ImageGallery/Spinner"; import Toolbar from "@mui/material/Toolbar"; +import { Chip, Divider } from "@mui/material"; +import { FolderGallery } from "./ImageGallery/FolderGallery"; const drawerWidth = 240; export const smallScreenMediaQuery = `(min-width:${drawerWidth * 3}px)`; @@ -19,7 +25,9 @@ function ImageGalleryLayout() { const [images, setImages] = useState([]); const [folders, setFolders] = useState(undefined); - + const [foldersPreview, setFoldersPreview] = useState< + FolderPreview[] | undefined + >([]); const location = useLocation(); const navigate = useNavigate(); @@ -28,6 +36,7 @@ function ImageGalleryLayout() { } useEffect(() => { + setFoldersPreview(undefined); setImages([]); setError(false); setImagesLoaded(false); @@ -49,6 +58,15 @@ function ImageGalleryLayout() { setImagesLoaded(true); } }); + fetch(`/folderspreview${location.pathname}`, { + headers: { + Accept: "application/json", + }, + }) + .then((res) => res.json()) + .then((data) => { + setFoldersPreview(data); + }); }, [location.pathname]); useEffect(() => { @@ -85,7 +103,32 @@ function ImageGalleryLayout() { /> - {imagesLoaded ? : } + {!imagesLoaded || !foldersPreview ? ( + + ) : ( + <> + {foldersPreview.length > 0 && ( + <> + + + + + + )} + {images.length > 0 && foldersPreview.length > 0 && ( + + + + )} + {images.length > 0 && } + {images.length == 0 && foldersPreview.length == 0 && ( +

+ No images available. You may want to add images in your root + directory. +

+ )} + + )}
); diff --git a/picture-gallery-client/src/util/responsive.ts b/picture-gallery-client/src/util/responsive.ts new file mode 100644 index 0000000..7f66927 --- /dev/null +++ b/picture-gallery-client/src/util/responsive.ts @@ -0,0 +1,12 @@ +import useMediaQuery from "@mui/material/useMediaQuery"; + +const breakpoints = Object.freeze([1200, 600, 300, 0]); + +// TODO: never can be one column at the moment +export function useColumns(): number { + const values = [5, 4, 3, 2]; + const index = breakpoints + .map((b) => useMediaQuery(`(min-width:${b}px)`)) + .findIndex((a) => a); + return Math.max(values[Math.max(index, 0)], 1); +} diff --git a/picture-gallery-client/vite.config.ts b/picture-gallery-client/vite.config.ts index ef943f1..2ea3d51 100644 --- a/picture-gallery-client/vite.config.ts +++ b/picture-gallery-client/vite.config.ts @@ -31,6 +31,11 @@ export default defineConfig(() => { changeOrigin: true, secure: false, }, + "/folderspreview": { + target: "http://localhost:3001", + changeOrigin: true, + secure: false, + }, "/staticImages": { target: "http://localhost:3001", changeOrigin: true, diff --git a/picture-gallery-server/src/controller/common.ts b/picture-gallery-server/src/controller/common.ts new file mode 100644 index 0000000..a470d39 --- /dev/null +++ b/picture-gallery-server/src/controller/common.ts @@ -0,0 +1,29 @@ +import express from "express"; +import path from "path"; +import fs, { Dirent } from "fs"; +import { thumbnailPath, thumbnailPublicPath } from "../paths"; + +export const getRequestedPath = (req: express.Request): string => + req.params[1] === undefined || req.params[1] === "/" ? "" : req.params[1]; + +export const readThumbnails = (requestedPath: string): string[] => { + const requestedThumbnailPath = path.posix.join( + thumbnailPublicPath, + requestedPath, + ); + return fs.existsSync(requestedThumbnailPath) + ? fs.readdirSync(requestedThumbnailPath) + : []; +}; + +export const getSrc = (requestedPath: string, f: Dirent): string => + path.posix.join("/staticImages", requestedPath, f.name); + +export const getThumbnail = ( + thumbnailExists: boolean, + requestedPath: string, + f: Dirent, +): string => + thumbnailExists + ? path.posix.join("/staticImages", thumbnailPath, requestedPath, f.name) + : getSrc(requestedPath, f); diff --git a/picture-gallery-server/src/controller/directories.ts b/picture-gallery-server/src/controller/directories.ts new file mode 100644 index 0000000..72e8d2b --- /dev/null +++ b/picture-gallery-server/src/controller/directories.ts @@ -0,0 +1,95 @@ +import fs from "fs"; +import * as path from "path"; +import express from "express"; +import { a, FolderPreview, Folders } from "../models"; +import { publicPath, thumbnailPath, thumbnailPublicPath } from "../paths"; +import { securityValidation } from "./securityChecks"; +import { getRequestedPath, getThumbnail } from "./common"; +import { consoleLogger } from "../logging"; + +export const walk = async (dirPath: string): Promise => { + const dirEnts = fs.readdirSync(path.posix.join(publicPath, dirPath), { + withFileTypes: true, + }); + + const numberOfFiles = dirEnts.filter((f) => f.isFile()).length; + + const children = await Promise.all( + dirEnts + .filter((d) => d.isDirectory()) + .filter((d) => !d.name.includes(thumbnailPath.substring(1))) + .map((d) => walk(path.posix.join(dirPath, d.name))), + ); + + return { + name: path.basename(dirPath) || "Home", + fullPath: dirPath, + numberOfFiles, + children, + }; +}; + +const getFirstImageInFolder = (dirPath: string): string | undefined => { + const dirs = [dirPath]; + while (dirs.length > 0) { + const curPath = dirs.shift(); + const dirContent = fs.readdirSync(path.posix.join(publicPath, curPath), { + withFileTypes: true, + }); + for (let i = 0; i < dirContent.length; i += 1) { + const content = dirContent[i]; + const filePath = path.posix.join(curPath, content.name); + if (content.isFile()) { + return getThumbnail( + fs.existsSync(path.posix.join(thumbnailPublicPath, filePath)), + curPath, + content, + ); + } + if (content.isDirectory()) { + dirs.push(filePath); + } + } + } + return undefined; +}; + +const getNumberOfFiles = (dirPath: string): number => + fs + .readdirSync(path.posix.join(publicPath, dirPath), { + withFileTypes: true, + }) + .filter((d) => d.isFile()).length; + +export const getFolderPreview = ( + req: express.Request, + res: express.Response, +) => { + const requestedPath = getRequestedPath(req); + + try { + securityValidation(requestedPath); + + const dirents = fs + .readdirSync(path.posix.join(publicPath, requestedPath), { + withFileTypes: true, + }) + .filter((d) => d.isDirectory()) + .filter((d) => !d.name.includes(thumbnailPath.substring(1))); + + res.json( + dirents.map((dir) => { + const fullPath = path.posix.join(requestedPath, dir.name); + return a({ + name: dir.name, + fullPath, + numberOfFiles: getNumberOfFiles(fullPath), + imagePreviewSrc: getFirstImageInFolder(fullPath), + }); + }), + ); + } catch (e) { + consoleLogger.warn(`Error when trying to access ${req.path}: ${e}`); + res.status(400).json({ message: `Path ${req.path} not accessible.` }); + } +}; diff --git a/picture-gallery-server/src/controller/images.ts b/picture-gallery-server/src/controller/images.ts index f1dc909..614ef5a 100644 --- a/picture-gallery-server/src/controller/images.ts +++ b/picture-gallery-server/src/controller/images.ts @@ -3,48 +3,29 @@ import express from "express"; import sharp from "sharp"; import path from "path"; import natsort from "natsort"; -import { publicPath, thumbnailPath, thumbnailPublicPath } from "../paths"; +import { publicPath } from "../paths"; import { a, Folder, Image } from "../models"; import { createThumbnailAsyncForImage } from "../thumbnails"; import { consoleLogger } from "../logging"; import { securityValidation } from "./securityChecks"; +import { + getRequestedPath, + getSrc, + getThumbnail, + readThumbnails, +} from "./common"; const notEmpty = ( - value: TValue | void | null | undefined + value: TValue | void | null | undefined, ): value is TValue => { return value !== null && value !== undefined; }; -const getRequestedPath = (req: express.Request): string => - req.params[1] === undefined || req.params[1] === "/" ? "" : req.params[1]; - -const readThumbnails = (requestedPath: string): string[] => { - const requestedThumbnailPath = path.posix.join( - thumbnailPublicPath, - requestedPath - ); - return fs.existsSync(requestedThumbnailPath) - ? fs.readdirSync(requestedThumbnailPath) - : []; -}; - -const getSrc = (requestedPath: string, f: Dirent): string => - path.posix.join("/staticImages", requestedPath, f.name); - -const getThumbnail = ( - thumbnailExists: boolean, - requestedPath: string, - f: Dirent -): string => - thumbnailExists - ? path.posix.join("/staticImages", thumbnailPath, requestedPath, f.name) - : getSrc(requestedPath, f); - const toImage = ( metadata: sharp.Metadata, thumbnailExists: boolean, requestedPath: string, - f: Dirent + f: Dirent, ): Image => { const widthAndHeightSwap = metadata.orientation > 4; // see https://exiftool.org/TagNames/EXIF.html return a({ @@ -58,7 +39,7 @@ const toImage = ( const getImagesToBeLoaded = ( dirents: Dirent[], thumbnails: string[], - requestedPath: string + requestedPath: string, ): Promise[] => dirents .filter((f) => f.isFile()) @@ -73,22 +54,22 @@ const getImagesToBeLoaded = ( return sharp(path.posix.join(publicPath, requestedPath, f.name)) .metadata() .then((metadata) => - toImage(metadata, thumbnailExists, requestedPath, f) + toImage(metadata, thumbnailExists, requestedPath, f), ) .catch((err) => { consoleLogger.error( `Reading metadata from ${path.posix.join( publicPath, requestedPath, - f.name - )} produced the following error: ${err.message}` + f.name, + )} produced the following error: ${err.message}`, ); }); }); export const getImages = async ( req: express.Request, - res: express.Response + res: express.Response, ) => { const requestedPath = getRequestedPath(req); @@ -103,7 +84,7 @@ export const getImages = async ( const imagesToBeLoaded = getImagesToBeLoaded( dirents, thumbnails, - requestedPath + requestedPath, ); const images = (await Promise.all(imagesToBeLoaded)).filter(notEmpty); res.json(a({ images })); diff --git a/picture-gallery-server/src/fsExtension.ts b/picture-gallery-server/src/fsExtension.ts deleted file mode 100644 index 69f6fd0..0000000 --- a/picture-gallery-server/src/fsExtension.ts +++ /dev/null @@ -1,26 +0,0 @@ -import fs from "fs"; -import * as path from "path"; -import { Folders } from "./models"; -import { publicPath, thumbnailPath } from "./paths"; - -export const walk = async (dirPath: string): Promise => { - const dirEnts = fs.readdirSync(path.posix.join(publicPath, dirPath), { - withFileTypes: true, - }); - - const numberOfFiles = dirEnts.filter((f) => f.isFile()).length; - - const children = await Promise.all( - dirEnts - .filter((d) => d.isDirectory()) - .filter((d) => !d.name.includes(thumbnailPath.substring(1))) - .map((d) => walk(path.posix.join(dirPath, d.name))), - ); - - return { - name: path.basename(dirPath) || "Home", - fullPath: dirPath, - numberOfFiles, - children, - }; -}; diff --git a/picture-gallery-server/src/models.ts b/picture-gallery-server/src/models.ts index 92b7e45..802aee5 100644 --- a/picture-gallery-server/src/models.ts +++ b/picture-gallery-server/src/models.ts @@ -16,6 +16,13 @@ export interface Folders { children: Folders[]; } +export interface FolderPreview { + name: string; + fullPath: string; + numberOfFiles: number; + imagePreviewSrc: string | undefined; +} + export const a = (v: T): T => { return v; }; diff --git a/picture-gallery-server/src/router/routerApi.ts b/picture-gallery-server/src/router/routerApi.ts index 828f6c6..fcfb526 100644 --- a/picture-gallery-server/src/router/routerApi.ts +++ b/picture-gallery-server/src/router/routerApi.ts @@ -1,6 +1,6 @@ import express from "express"; import { getImages } from "../controller/images"; -import { walk } from "../fsExtension"; +import { getFolderPreview, walk } from "../controller/directories"; export const routerApi = express.Router(); @@ -9,3 +9,5 @@ routerApi.get(`/images(/*)?`, getImages); routerApi.get("/directories", async (req, res) => { res.json(await walk("")); }); + +routerApi.get("/folderspreview(/*)?", getFolderPreview);