Add folders support

quite a few minor issues to clean up
This commit is contained in:
Stefan Forstenlechner 2024-08-18 21:32:00 +02:00
parent 0e28646b62
commit 30cdba8fd6
13 changed files with 276 additions and 78 deletions

View File

@ -25,6 +25,8 @@ RUN mkdir built && \
FROM node:16.14.2-alpine 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=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/ COPY --from=server-builder --chown=node:node /usr/src/app/picture-gallery-server/built /usr/src/app/picture-gallery-server/

View File

@ -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 (
<ImageList cols={columns} gap={8}>
{folders.map((folder) => (
<ImageListItem key={folder.fullPath}>
{/* Link and image styling taken from https://github.com/mui/material-ui/issues/22597 */}
<Link
to={folder.fullPath}
style={{ display: "block", height: "100%" }}
>
<img
src={folder.imagePreviewSrc}
alt={folder.name}
loading="lazy"
style={{ objectFit: "cover", width: "100%", height: "100%" }}
/>
<ImageListItemBar
title={folder.name}
actionIcon={
<Chip
label={folder.numberOfFiles}
size="small"
sx={{
color: "white",
backgroundColor: "rgba(255, 255, 255, 0.24);",
marginRight: 1,
}}
/>
}
/>
</Link>
</ImageListItem>
))}
</ImageList>
);
};

View File

@ -9,21 +9,15 @@ import Thumbnails from "yet-another-react-lightbox/plugins/thumbnails";
import "yet-another-react-lightbox/plugins/thumbnails.css"; import "yet-another-react-lightbox/plugins/thumbnails.css";
import Zoom from "yet-another-react-lightbox/plugins/zoom"; import Zoom from "yet-another-react-lightbox/plugins/zoom";
import { ImageList, ImageListItem } from "@mui/material"; 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); const [index, setIndex] = useState(-1);
const columns = useColumns();
if (images.length === 0) {
return (
<p>
No images available. You may want to add images in your root directory.
</p>
);
}
return ( return (
<> <>
<ImageList variant="masonry" cols={3} gap={8}> <ImageList variant="masonry" cols={columns} gap={8}>
{images.map((item, index) => ( {images.map((item, index) => (
<ImageListItem key={item.thumbnail}> <ImageListItem key={item.thumbnail}>
<img <img
@ -50,6 +44,4 @@ function ImageGallery({ images }: { images: ImageWithThumbnail[] }) {
/> />
</> </>
); );
} };
export default ImageGallery;

View File

@ -7,6 +7,13 @@ export interface Folders {
children: Folders[]; children: Folders[];
} }
export interface FolderPreview {
name: string;
fullPath: string;
numberOfFiles: number;
imagePreviewSrc: string | undefined;
}
export interface ImageWithThumbnail extends Slide { export interface ImageWithThumbnail extends Slide {
thumbnail: string; thumbnail: string;
} }

View File

@ -2,12 +2,18 @@ import React, { useEffect, useState } from "react";
import CssBaseline from "@mui/material/CssBaseline"; import CssBaseline from "@mui/material/CssBaseline";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import { useLocation, useNavigate } from "react-router-dom"; 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 { ImageGalleryAppBar } from "./ImageGallery/ImageGalleryAppBar";
import { ImageGalleryDrawer } from "./ImageGallery/ImageGalleryDrawer"; import { ImageGalleryDrawer } from "./ImageGallery/ImageGalleryDrawer";
import ImageGallery from "./ImageGallery/ImageGallery"; import { ImageGallery } from "./ImageGallery/ImageGallery";
import { Spinner } from "./ImageGallery/Spinner"; import { Spinner } from "./ImageGallery/Spinner";
import Toolbar from "@mui/material/Toolbar"; import Toolbar from "@mui/material/Toolbar";
import { Chip, Divider } from "@mui/material";
import { FolderGallery } from "./ImageGallery/FolderGallery";
const drawerWidth = 240; const drawerWidth = 240;
export const smallScreenMediaQuery = `(min-width:${drawerWidth * 3}px)`; export const smallScreenMediaQuery = `(min-width:${drawerWidth * 3}px)`;
@ -19,7 +25,9 @@ function ImageGalleryLayout() {
const [images, setImages] = useState<ImageWithThumbnail[]>([]); const [images, setImages] = useState<ImageWithThumbnail[]>([]);
const [folders, setFolders] = useState<Folders | undefined>(undefined); const [folders, setFolders] = useState<Folders | undefined>(undefined);
const [foldersPreview, setFoldersPreview] = useState<
FolderPreview[] | undefined
>([]);
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
@ -28,6 +36,7 @@ function ImageGalleryLayout() {
} }
useEffect(() => { useEffect(() => {
setFoldersPreview(undefined);
setImages([]); setImages([]);
setError(false); setError(false);
setImagesLoaded(false); setImagesLoaded(false);
@ -49,6 +58,15 @@ function ImageGalleryLayout() {
setImagesLoaded(true); setImagesLoaded(true);
} }
}); });
fetch(`/folderspreview${location.pathname}`, {
headers: {
Accept: "application/json",
},
})
.then((res) => res.json())
.then((data) => {
setFoldersPreview(data);
});
}, [location.pathname]); }, [location.pathname]);
useEffect(() => { useEffect(() => {
@ -85,7 +103,32 @@ function ImageGalleryLayout() {
/> />
<Box component="main" sx={{ flexGrow: 1, p: 3 }}> <Box component="main" sx={{ flexGrow: 1, p: 3 }}>
<Toolbar /> <Toolbar />
{imagesLoaded ? <ImageGallery images={images} /> : <Spinner />} {!imagesLoaded || !foldersPreview ? (
<Spinner />
) : (
<>
{foldersPreview.length > 0 && (
<>
<Divider>
<Chip label="Folders" size="small" />
</Divider>
<FolderGallery folders={foldersPreview} />
</>
)}
{images.length > 0 && foldersPreview.length > 0 && (
<Divider>
<Chip label="Images" size="small" />
</Divider>
)}
{images.length > 0 && <ImageGallery images={images} />}
{images.length == 0 && foldersPreview.length == 0 && (
<p>
No images available. You may want to add images in your root
directory.
</p>
)}
</>
)}
</Box> </Box>
</Box> </Box>
); );

View File

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

View File

@ -31,6 +31,11 @@ export default defineConfig(() => {
changeOrigin: true, changeOrigin: true,
secure: false, secure: false,
}, },
"/folderspreview": {
target: "http://localhost:3001",
changeOrigin: true,
secure: false,
},
"/staticImages": { "/staticImages": {
target: "http://localhost:3001", target: "http://localhost:3001",
changeOrigin: true, changeOrigin: true,

View File

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

View File

@ -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<Folders> => {
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<FolderPreview>({
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.` });
}
};

View File

@ -3,48 +3,29 @@ import express from "express";
import sharp from "sharp"; import sharp from "sharp";
import path from "path"; import path from "path";
import natsort from "natsort"; import natsort from "natsort";
import { publicPath, thumbnailPath, thumbnailPublicPath } from "../paths"; import { publicPath } from "../paths";
import { a, Folder, Image } from "../models"; import { a, Folder, Image } from "../models";
import { createThumbnailAsyncForImage } from "../thumbnails"; import { createThumbnailAsyncForImage } from "../thumbnails";
import { consoleLogger } from "../logging"; import { consoleLogger } from "../logging";
import { securityValidation } from "./securityChecks"; import { securityValidation } from "./securityChecks";
import {
getRequestedPath,
getSrc,
getThumbnail,
readThumbnails,
} from "./common";
const notEmpty = <TValue>( const notEmpty = <TValue>(
value: TValue | void | null | undefined value: TValue | void | null | undefined,
): value is TValue => { ): value is TValue => {
return value !== null && value !== undefined; 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 = ( const toImage = (
metadata: sharp.Metadata, metadata: sharp.Metadata,
thumbnailExists: boolean, thumbnailExists: boolean,
requestedPath: string, requestedPath: string,
f: Dirent f: Dirent,
): Image => { ): Image => {
const widthAndHeightSwap = metadata.orientation > 4; // see https://exiftool.org/TagNames/EXIF.html const widthAndHeightSwap = metadata.orientation > 4; // see https://exiftool.org/TagNames/EXIF.html
return a<Image>({ return a<Image>({
@ -58,7 +39,7 @@ const toImage = (
const getImagesToBeLoaded = ( const getImagesToBeLoaded = (
dirents: Dirent[], dirents: Dirent[],
thumbnails: string[], thumbnails: string[],
requestedPath: string requestedPath: string,
): Promise<Image | void>[] => ): Promise<Image | void>[] =>
dirents dirents
.filter((f) => f.isFile()) .filter((f) => f.isFile())
@ -73,22 +54,22 @@ const getImagesToBeLoaded = (
return sharp(path.posix.join(publicPath, requestedPath, f.name)) return sharp(path.posix.join(publicPath, requestedPath, f.name))
.metadata() .metadata()
.then((metadata) => .then((metadata) =>
toImage(metadata, thumbnailExists, requestedPath, f) toImage(metadata, thumbnailExists, requestedPath, f),
) )
.catch((err) => { .catch((err) => {
consoleLogger.error( consoleLogger.error(
`Reading metadata from ${path.posix.join( `Reading metadata from ${path.posix.join(
publicPath, publicPath,
requestedPath, requestedPath,
f.name f.name,
)} produced the following error: ${err.message}` )} produced the following error: ${err.message}`,
); );
}); });
}); });
export const getImages = async ( export const getImages = async (
req: express.Request, req: express.Request,
res: express.Response res: express.Response,
) => { ) => {
const requestedPath = getRequestedPath(req); const requestedPath = getRequestedPath(req);
@ -103,7 +84,7 @@ export const getImages = async (
const imagesToBeLoaded = getImagesToBeLoaded( const imagesToBeLoaded = getImagesToBeLoaded(
dirents, dirents,
thumbnails, thumbnails,
requestedPath requestedPath,
); );
const images = (await Promise.all(imagesToBeLoaded)).filter(notEmpty); const images = (await Promise.all(imagesToBeLoaded)).filter(notEmpty);
res.json(a<Folder>({ images })); res.json(a<Folder>({ images }));

View File

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

View File

@ -16,6 +16,13 @@ export interface Folders {
children: Folders[]; children: Folders[];
} }
export interface FolderPreview {
name: string;
fullPath: string;
numberOfFiles: number;
imagePreviewSrc: string | undefined;
}
export const a = <T>(v: T): T => { export const a = <T>(v: T): T => {
return v; return v;
}; };

View File

@ -1,6 +1,6 @@
import express from "express"; import express from "express";
import { getImages } from "../controller/images"; import { getImages } from "../controller/images";
import { walk } from "../fsExtension"; import { getFolderPreview, walk } from "../controller/directories";
export const routerApi = express.Router(); export const routerApi = express.Router();
@ -9,3 +9,5 @@ routerApi.get(`/images(/*)?`, getImages);
routerApi.get("/directories", async (req, res) => { routerApi.get("/directories", async (req, res) => {
res.json(await walk("")); res.json(await walk(""));
}); });
routerApi.get("/folderspreview(/*)?", getFolderPreview);