parent
0e28646b62
commit
30cdba8fd6
|
|
@ -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/
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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 (
|
||||
<p>
|
||||
No images available. You may want to add images in your root directory.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
const columns = useColumns();
|
||||
|
||||
return (
|
||||
<>
|
||||
<ImageList variant="masonry" cols={3} gap={8}>
|
||||
<ImageList variant="masonry" cols={columns} gap={8}>
|
||||
{images.map((item, index) => (
|
||||
<ImageListItem key={item.thumbnail}>
|
||||
<img
|
||||
|
|
@ -50,6 +44,4 @@ function ImageGallery({ images }: { images: ImageWithThumbnail[] }) {
|
|||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ImageGallery;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ImageWithThumbnail[]>([]);
|
||||
|
||||
const [folders, setFolders] = useState<Folders | undefined>(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() {
|
|||
/>
|
||||
<Box component="main" sx={{ flexGrow: 1, p: 3 }}>
|
||||
<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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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.` });
|
||||
}
|
||||
};
|
||||
|
|
@ -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 = <TValue>(
|
||||
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<Image>({
|
||||
|
|
@ -58,7 +39,7 @@ const toImage = (
|
|||
const getImagesToBeLoaded = (
|
||||
dirents: Dirent[],
|
||||
thumbnails: string[],
|
||||
requestedPath: string
|
||||
requestedPath: string,
|
||||
): Promise<Image | void>[] =>
|
||||
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<Folder>({ images }));
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
@ -16,6 +16,13 @@ export interface Folders {
|
|||
children: Folders[];
|
||||
}
|
||||
|
||||
export interface FolderPreview {
|
||||
name: string;
|
||||
fullPath: string;
|
||||
numberOfFiles: number;
|
||||
imagePreviewSrc: string | undefined;
|
||||
}
|
||||
|
||||
export const a = <T>(v: T): T => {
|
||||
return v;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue