Compare commits

..

16 Commits

Author SHA1 Message Date
Stefan Forstenlechner 024dc2ca4f Add demo workflow
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 1m11s Details
2024-08-19 19:24:36 +02:00
Stefan Forstenlechner 16c6413a45 Update/fix docker build 2024-08-19 19:18:58 +02:00
Stefan Forstenlechner 9b8d4c76fb Remove unnecessary TODOs
ad responsive.ts: min with 2 columns is fine
2024-08-19 13:09:07 +02:00
Stefan Forstenlechner 02098fd2ea migrate jest to vitest in client 2024-08-19 13:06:58 +02:00
Stefan Forstenlechner 5e860d0ea4 Use stencil to let pictures look like folders 2024-08-19 12:47:38 +02:00
Stefan Forstenlechner 30cdba8fd6 Add folders support
quite a few minor issues to clean up
2024-08-18 21:35:38 +02:00
Stefan Forstenlechner 0e28646b62 Remove image check when loading number of files
Checking if an image is processable when loading the directory slowed
 down the initial loading of the directories significantly.
7ms vs 180ms in a small test with about 400 images.
User should not have any other files in these folders.
Other methods still contain sanity checks.
2024-08-15 23:33:40 +02:00
Stefan Forstenlechner 77348a9323 Remove default folder
It looked weird having a folder shown, while loading the folder
 structure
2024-08-15 20:34:20 +02:00
Stefan Forstenlechner 66962106d3 remove unused code 2024-08-15 20:34:20 +02:00
Stefan Forstenlechner e28475c5aa Move images to another folder
server handles requests correctly by redirecting either to FE or
 handling the request. I could not find a proper way to do that for the
 client. So lets just avoid using folder names that are API paths
2024-08-15 20:34:20 +02:00
Stefan Forstenlechner 0ae8a1f257 improve styling 2024-08-15 20:34:20 +02:00
Stefan Forstenlechner a82441d671 split between api and html router
also fix set-environment
2024-08-15 20:34:20 +02:00
Stefan Forstenlechner 59d12418ff replace react-photo-album with mui image list 2024-08-15 20:34:20 +02:00
Stefan Forstenlechner a71a763532 fix env transformation during runtime 2024-08-15 20:34:19 +02:00
Stefan Forstenlechner 210cd74155 migrate to vite + update dependencies 2024-08-15 20:34:18 +02:00
Stefan Forstenlechner 5b3f77c794 update server versions 2024-08-12 12:18:53 +02:00
50 changed files with 9966 additions and 32526 deletions

View File

@ -0,0 +1,19 @@
name: Gitea Actions Demo
run-name: ${{ gitea.actor }} is testing out Gitea Actions 🚀
on: [push]
jobs:
Explore-Gitea-Actions:
runs-on: ubuntu-latest
steps:
- run: echo "🎉 The job was automatically triggered by a ${{ gitea.event_name }} event."
- run: echo "🐧 This job is now running on a ${{ runner.os }} server hosted by Gitea!"
- run: echo "🔎 The name of your branch is ${{ gitea.ref }} and your repository is ${{ gitea.repository }}."
- name: Check out repository code
uses: actions/checkout@v4
- run: echo "💡 The ${{ gitea.repository }} repository has been cloned to the runner."
- run: echo "🖥️ The workflow is now ready to test your code on the runner."
- name: List files in the repository
run: |
ls ${{ gitea.workspace }}
- run: echo "🍏 This job's status is ${{ job.status }}."

View File

@ -1,4 +1,4 @@
FROM node:16.14.2-alpine as client-builder FROM node:22.2.0-alpine as client-builder
COPY picture-gallery-client/package*.json /usr/src/app/picture-gallery-client/ COPY picture-gallery-client/package*.json /usr/src/app/picture-gallery-client/
WORKDIR /usr/src/app/picture-gallery-client WORKDIR /usr/src/app/picture-gallery-client
@ -10,9 +10,11 @@ RUN mkdir built && \
mv build built && \ mv build built && \
mv package.minimize.docker.json built/package.json && \ mv package.minimize.docker.json built/package.json && \
cp package-lock.json built && \ cp package-lock.json built && \
cp .env.example built && \
cp .env built && \
npm ci --prefix ./built --only=production npm ci --prefix ./built --only=production
FROM node:16.14.2-alpine as server-builder FROM node:22.2.0-alpine as server-builder
COPY picture-gallery-server/package*.json /usr/src/app/picture-gallery-server/ COPY picture-gallery-server/package*.json /usr/src/app/picture-gallery-server/
WORKDIR /usr/src/app/picture-gallery-server WORKDIR /usr/src/app/picture-gallery-server
@ -23,7 +25,9 @@ RUN npm run server:build
RUN mkdir built && \ RUN mkdir built && \
mv dist node_modules built mv dist node_modules built
FROM node:16.14.2-alpine FROM node:22.2.0-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

@ -1,31 +1,35 @@
# Simple Picture Gallery # Simple Picture Gallery
## Getting Started ## Getting Started
### Docker ### Information
```shell Folders should only contain images and folders. Folders should not contain any other files.
docker build . -t simple-picture-gallery
docker run -p 3005:3001 -v /mnt/data/pictures:/usr/src/app/public --name my-picture-gallery simple-picture-gallery ### Docker
```
```shell
### Customization docker build . -t simple-picture-gallery
docker run -p 3005:3001 -v /mnt/data/pictures:/usr/src/app/public --name my-picture-gallery simple-picture-gallery
Create an environment file `.env`: ```
```properties ### Customization
REACT_APP_TITLE=My Gallery
REACT_APP_APPBAR_COLOR=#F8AB2D Create an environment file `.env`:
```
```properties
Other properties: VITE_TITLE=My Gallery
VITE_APPBAR_COLOR=#F8AB2D
```properties ```
REACT_APP_FAVICON_HREF=<URL to your favicon>
``` Other properties:
And run docker with `--env-file .env` ```properties
VITE_FAVICON_HREF=<URL to your favicon>
```shell ```
docker run -p 3005:3001 -v C:/DATA/temp/bla:/usr/src/app/public --env-file .env --name my-picture-gallery simple-picture-gallery
And run docker with `--env-file .env`
```shell
docker run -p 3005:3001 -v C:/DATA/temp/bla:/usr/src/app/public --env-file .env --name my-picture-gallery simple-picture-gallery
``` ```

View File

@ -2,7 +2,8 @@
"name": "simple-picture-gallery", "name": "simple-picture-gallery",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"concurrently": "7.2.1" "concurrently": "7.2.1",
"npm-check-updates": "^17.0.6"
}, },
"scripts": { "scripts": {
"install-all": "npm i && concurrently npm:install:client npm:install:server", "install-all": "npm i && concurrently npm:install:client npm:install:server",
@ -11,6 +12,8 @@
"start-all": "concurrently npm:run:client npm:run:server", "start-all": "concurrently npm:run:client npm:run:server",
"run:client": "npm run --prefix picture-gallery-client client:run", "run:client": "npm run --prefix picture-gallery-client client:run",
"run:server": "npm run --prefix picture-gallery-server server:run", "run:server": "npm run --prefix picture-gallery-server server:run",
"update:client": "ncu --cwd picture-gallery-client",
"update:server": "ncu --cwd picture-gallery-server",
"docker:buildImage": "docker build . -t simple-picture-gallery" "docker:buildImage": "docker build . -t simple-picture-gallery"
} }
} }

View File

@ -1,3 +1,4 @@
# Set user specific variables # Set user specific variables
REACT_APP_TITLE=Simple Picture Gallery VITE_TITLE=Simple Picture Gallery
REACT_APP_APPBAR_COLOR=#1976D2 VITE_APPBAR_COLOR=#1976D2
VITE_FAVICON_HREF=/favicon.ico

View File

@ -0,0 +1,4 @@
# Set user specific variables
VITE_TITLE=
VITE_APPBAR_COLOR=
VITE_FAVICON_HREF=

View File

@ -1,8 +1,7 @@
{ {
"env": { "env": {
"browser": true, "browser": true,
"es2021": true, "es2021": true
"jest/globals": true
}, },
"extends": [ "extends": [
"plugin:react/recommended", "plugin:react/recommended",
@ -19,8 +18,7 @@
}, },
"plugins": [ "plugins": [
"react", "react",
"@typescript-eslint", "@typescript-eslint"
"jest"
], ],
"ignorePatterns": [ "ignorePatterns": [
"**/*.css" "**/*.css"
@ -55,6 +53,9 @@
"import/resolver": { "import/resolver": {
"typescript": { "typescript": {
} }
},
"react": {
"version": "detect"
} }
} }
} }

View File

@ -2,28 +2,19 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="icon" id="favicon" href="%PUBLIC_URL%/favicon.ico" /> <link rel="icon" id="favicon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" /> <meta name="theme-color" content="#000000" />
<meta <meta
name="description" name="description"
content="Simple Picture Gallery" content="Simple Picture Gallery"
/> />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" /> <link rel="apple-touch-icon" href="/logo192.png" />
<!-- <!--
manifest.json provides metadata used when your web app is installed on a manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/ user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
--> -->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" /> <link rel="manifest" href="/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title id="appTitle">Simple Picture Gallery</title> <title id="appTitle">Simple Picture Gallery</title>
</head> </head>
<body> <body>
@ -39,6 +30,9 @@
To begin the development, run `npm start` or `yarn start`. To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`. To create a production bundle, use `npm run build` or `yarn build`.
--> -->
<script src='%PUBLIC_URL%/env.js'></script> <script>
globalThis.import_meta_env = JSON.parse('"import_meta_env_placeholder"');
</script>
<script type="module" src="/src/index.tsx"></script>
</body> </body>
</html> </html>

File diff suppressed because it is too large Load Diff

View File

@ -3,52 +3,65 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"proxy": "http://localhost:3001", "proxy": "http://localhost:3001",
"dependencies": { "type": "module",
"@emotion/react": "11.9.0",
"@emotion/styled": "11.8.1",
"@mui/icons-material": "5.6.0",
"@mui/lab": "5.0.0-alpha.76",
"@mui/material": "5.6.0",
"@types/jest": "27.4.1",
"@types/node": "16.11.26",
"@types/react": "18.0.0",
"@types/react-dom": "18.0.0",
"eslint": "8.21.0",
"eslint-config-prettier": "8.5.0",
"eslint-import-resolver-typescript": "3.4.0",
"eslint-plugin-import": "2.26.0",
"eslint-plugin-jsx-a11y": "6.6.1",
"eslint-plugin-prettier": "4.2.1",
"eslint-plugin-react": "7.30.1",
"eslint-plugin-react-hooks": "4.4.0",
"eslint-plugin-jest": "26.5.3",
"react": "18.0.0",
"react-dom": "18.0.0",
"react-photo-album": "1.12.0",
"react-router-dom": "6.3.0",
"react-scripts": "5.0.0",
"react-inject-env": "2.1.0",
"typescript": "4.6.3",
"web-vitals": "2.1.4",
"yet-another-react-lightbox": "1.14.0"
},
"scripts": { "scripts": {
"format": "prettier --write \"**/*.+(ts|tsx)\"", "format": "prettier --write \"**/*.+(ts|tsx)\"",
"format:check": "prettier --check \"**/*.+(ts|tsx)\"", "format:check": "prettier --check \"**/*.+(ts|tsx)\"",
"lint": "eslint src/**", "lint": "eslint src/**",
"lint:fix": "eslint --fix src/**", "lint:fix": "eslint --fix src/**",
"client:build": "react-scripts build && npm run set-environment", "client:build": "vite build",
"client:eject": "react-scripts eject",
"client:run": "npm run client:build && npm run client:start", "client:run": "npm run client:build && npm run client:start",
"client:start": "react-scripts start", "client:start": "vite",
"client:test": "react-scripts test", "set-environment": "npx import-meta-env -x .env.example",
"set-environment": "npx react-inject-env set", "client:test": "vitest",
"test": "jest" "client:coverage": "vitest run --coverage"
},
"dependencies": {
"@emotion/react": "^11.13.0",
"@emotion/styled": "^11.13.0",
"@import-meta-env/cli": "^0.6.9",
"@import-meta-env/unplugin": "^0.5.2",
"@mui/icons-material": "^5.16.7",
"@mui/material": "^5.16.7",
"@mui/x-tree-view": "^7.12.1",
"@types/node": "^22.2.0",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "8.0.1",
"@typescript-eslint/parser": "8.0.1",
"@vitejs/plugin-react": "^4.3.1",
"cross-env": "^7.0.3",
"dotenv": "^16.4.5",
"eslint": "8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-import-resolver-typescript": "^3.6.1",
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-jsx-a11y": "^6.9.0",
"eslint-plugin-prettier": "^5.2.1",
"eslint-plugin-react": "^7.35.0",
"eslint-plugin-react-hooks": "^4.6.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.0",
"typescript": "^5.5.4",
"vite": "^5.4.0",
"vite-plugin-eslint": "^1.8.1",
"vite-tsconfig-paths": "^5.0.1",
"web-vitals": "^4.2.3",
"yet-another-react-lightbox": "^3.21.3"
},
"devDependencies": {
"@testing-library/react": "^16.0.0",
"@testing-library/user-event": "^14.5.2",
"@vitest/coverage-v8": "^2.0.5",
"jsdom": "^24.1.1",
"prettier": "^3.3.3",
"ts-node": "^10.9.2",
"vitest": "^2.0.5"
}, },
"eslintConfig": { "eslintConfig": {
"extends": [ "extends": [
"react-app", "react-app"
"react-app/jest"
] ]
}, },
"browserslist": { "browserslist": {
@ -62,15 +75,5 @@
"last 1 firefox version", "last 1 firefox version",
"last 1 safari version" "last 1 safari version"
] ]
},
"devDependencies": {
"@testing-library/jest-dom": "5.16.5",
"@testing-library/react": "13.3.0",
"@testing-library/user-event": "14.4.2",
"@typescript-eslint/eslint-plugin": "5.32.0",
"@typescript-eslint/parser": "5.32.0",
"prettier": "2.7.1",
"ts-jest": "27.1.5",
"ts-node": "10.7.0"
} }
} }

View File

@ -3,9 +3,9 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"dependencies": { "dependencies": {
"react-inject-env": "2.1.0" "@import-meta-env/cli": "^0.6.9"
}, },
"scripts": { "scripts": {
"set-environment": "npx react-inject-env set" "set-environment": "npx import-meta-env -x .env.example"
} }
} }

View File

@ -0,0 +1,74 @@
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} style={{ overflowY: "initial" }}>
{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%",
clipPath: "url(#folderPath)",
}}
/>
<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>
{/* External svg does not seem to work (anymore?) */}
{/* see for example https://codepen.io/imohkay/pen/GJpxXY */}
<svg
height={0}
width={0}
// Make sure svg does not take up any space
style={{ position: "absolute" }}
>
<defs>
<clipPath id="folderPath" clipPathUnits="objectBoundingBox">
{/* Taken from MUI Folder Icon*/}
<path
transform="translate(-0.1 -0.25) scale(0.05 0.0625)"
d="M10 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8z"
></path>
</clipPath>
</defs>
</svg>
</>
);
};

View File

@ -1,22 +0,0 @@
import { PhotoProps } from "react-photo-album";
import { ImageWithThumbnail } from "./models";
import React from "react";
export const Image = <T extends ImageWithThumbnail>({
imageProps: { alt, style, src: _useSrcAndThumbnailFromPhoto, ...rest },
photo,
}: PhotoProps<T>): JSX.Element => {
return (
<>
<img
alt={alt}
style={{
...style,
}}
{...rest}
src={photo.thumbnail}
loading={"lazy"}
/>
</>
);
};

View File

@ -1,38 +1,33 @@
import PhotoAlbum from "react-photo-album";
import React, { useState } from "react"; import React, { useState } from "react";
import { Image } from "./Image";
import { ImageWithThumbnail } from "./models"; import { ImageWithThumbnail } from "./models";
import { Lightbox } from "yet-another-react-lightbox"; import { Lightbox } from "yet-another-react-lightbox";
import "yet-another-react-lightbox/styles.css"; import "yet-another-react-lightbox/styles.css";
import { Fullscreen } from "yet-another-react-lightbox/plugins/fullscreen"; import Fullscreen from "yet-another-react-lightbox/plugins/fullscreen";
import { Slideshow } from "yet-another-react-lightbox/plugins/slideshow"; import Slideshow from "yet-another-react-lightbox/plugins/slideshow";
import { Thumbnails } from "yet-another-react-lightbox/plugins/thumbnails"; 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 { 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>
);
}
// For all kind of settings see:
// https://react-photo-album.com/examples/playground
// https://codesandbox.io/s/github/igordanchenko/react-photo-album/tree/main/examples/playground
return ( return (
<> <>
<PhotoAlbum <ImageList variant="masonry" cols={columns} gap={8}>
layout="masonry" {images.map((item, index) => (
photos={images} <ImageListItem key={item.thumbnail}>
renderPhoto={Image} <img
onClick={(event, photo, index) => setIndex(index)} src={item.thumbnail}
/> loading="lazy"
onClick={() => setIndex(index)}
/>
</ImageListItem>
))}
</ImageList>
<Lightbox <Lightbox
slides={images} slides={images}
open={index >= 0} open={index >= 0}
@ -49,6 +44,4 @@ function ImageGallery({ images }: { images: ImageWithThumbnail[] }) {
/> />
</> </>
); );
} };
export default ImageGallery;

View File

@ -1,7 +1,6 @@
import Toolbar from "@mui/material/Toolbar"; import Toolbar from "@mui/material/Toolbar";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import React from "react"; import React from "react";
import env from "../env";
import AppBar from "@mui/material/AppBar"; import AppBar from "@mui/material/AppBar";
import useMediaQuery from "@mui/material/useMediaQuery"; import useMediaQuery from "@mui/material/useMediaQuery";
import { IconButton } from "@mui/material"; import { IconButton } from "@mui/material";
@ -21,7 +20,9 @@ export const ImageGalleryAppBar = ({
<AppBar <AppBar
position="fixed" position="fixed"
sx={{ zIndex: (theme) => theme.zIndex.drawer + 1 }} sx={{ zIndex: (theme) => theme.zIndex.drawer + 1 }}
style={{ backgroundColor: env.REACT_APP_APPBAR_COLOR ?? "#1976D2" }} style={{
backgroundColor: import.meta.env.VITE_APPBAR_COLOR ?? "#1976D2",
}}
> >
<Toolbar> <Toolbar>
{smallScreen && ( {smallScreen && (
@ -36,7 +37,7 @@ export const ImageGalleryAppBar = ({
</IconButton> </IconButton>
)} )}
<Typography variant="h6" noWrap component="div"> <Typography variant="h6" noWrap component="div">
{env.REACT_APP_TITLE ?? "Simple Picture Gallery"} {import.meta.env.VITE_TITLE ?? "Simple Picture Gallery"}
</Typography> </Typography>
</Toolbar> </Toolbar>
</AppBar> </AppBar>

View File

@ -1,31 +1,39 @@
import Drawer from "@mui/material/Drawer"; import Drawer from "@mui/material/Drawer";
import FolderIcon from "@mui/icons-material/Folder"; import FolderIcon from "@mui/icons-material/Folder";
import FolderOpenIcon from "@mui/icons-material/FolderOpen"; import FolderOpenIcon from "@mui/icons-material/FolderOpen";
import { TreeItem, TreeView } from "@mui/lab"; import PhotoOutlined from "@mui/icons-material/PhotoOutlined";
import { SimpleTreeView, TreeItem } from "@mui/x-tree-view";
import { useLocation, useNavigate } from "react-router-dom"; import { useLocation, useNavigate } from "react-router-dom";
import React, { useState } from "react"; import React, { useMemo, useState } from "react";
import { Folders } from "./models"; import { Folders } from "./models";
import Toolbar from "@mui/material/Toolbar"; import Toolbar from "@mui/material/Toolbar";
import { useTheme } from "@mui/material"; import { Chip, useTheme } from "@mui/material";
import useMediaQuery from "@mui/material/useMediaQuery"; import useMediaQuery from "@mui/material/useMediaQuery";
import { smallScreenMediaQuery } from "../ImageGalleryLayout"; import { smallScreenMediaQuery } from "../ImageGalleryLayout";
import { getDefaultExpanded } from "./PathToExpaned"; import { getDefaultExpanded } from "./PathToExpaned";
import Typography from "@mui/material/Typography";
function generateTreeViewChildren( function generateTreeViewChildren(
folders: Folders[], folders: Folders[],
navigateAndToggleExpand: (_path: string, _navigationAllowed: boolean) => void navigateAndToggleExpand: (_path: string, _navigationAllowed: boolean) => void,
) { ) {
return ( return (
<> <>
{folders.map((f) => { {folders.map((f) => {
const label = const label =
f.numberOfFiles === 0 ? f.name : `${f.name} - (${f.numberOfFiles})`; f.numberOfFiles === 0 ? (
f.name
) : (
<>
{f.name} <Chip label={f.numberOfFiles} size="small" />
</>
);
const containsImages = f.numberOfFiles > 0; const containsImages = f.numberOfFiles > 0;
if (f.children.length === 0) { if (f.children.length === 0) {
return ( return (
<TreeItem <TreeItem
key={f.fullPath} key={f.fullPath}
nodeId={f.fullPath} itemId={f.fullPath}
label={label} label={label}
onClick={() => onClick={() =>
navigateAndToggleExpand(f.fullPath, containsImages) navigateAndToggleExpand(f.fullPath, containsImages)
@ -36,7 +44,7 @@ function generateTreeViewChildren(
return ( return (
<TreeItem <TreeItem
key={f.fullPath} key={f.fullPath}
nodeId={f.fullPath} itemId={f.fullPath}
label={label} label={label}
onClick={() => navigateAndToggleExpand(f.fullPath, containsImages)} onClick={() => navigateAndToggleExpand(f.fullPath, containsImages)}
> >
@ -48,24 +56,40 @@ function generateTreeViewChildren(
); );
} }
const calcFolderWithItem = (
cur: Folders,
calculated: Set<string>,
): Set<string> => {
if (cur.numberOfFiles > 0 || cur.children.length == 0) {
calculated.add(cur.fullPath);
}
cur.children.forEach((a) => calcFolderWithItem(a, calculated));
return calculated;
};
const GenerateTreeView = ({ root }: { root: Folders }) => { const GenerateTreeView = ({ root }: { root: Folders }) => {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const [expanded, setExpanded] = useState<string[]>( const folderFullPathContainingPhotos = useMemo(
getDefaultExpanded(location.pathname) () => calcFolderWithItem(root, new Set()),
[root],
); );
const [expandedItems, setExpandedItems] = useState<string[]>(
getDefaultExpanded(location.pathname),
);
const [selectedItem, setSelectedItem] = useState<string>(location.pathname);
const toggleExpanded = (path: string) => { const toggleExpanded = (path: string) => {
if (expanded.includes(path)) { if (expandedItems.includes(path)) {
setExpanded(expanded.filter((p) => p !== path)); setExpandedItems(expandedItems.filter((p) => p !== path));
} else { } else {
setExpanded([path, ...expanded]); setExpandedItems([path, ...expandedItems]);
} }
}; };
const navigateAndToggleExpand = ( const navigateAndToggleExpand = (
path: string, path: string,
navigationAllowed: boolean navigationAllowed: boolean,
) => { ) => {
if (!navigationAllowed || location.pathname === path) { if (!navigationAllowed || location.pathname === path) {
toggleExpanded(path); toggleExpanded(path);
@ -76,16 +100,28 @@ const GenerateTreeView = ({ root }: { root: Folders }) => {
}; };
return ( return (
<TreeView <SimpleTreeView
disableSelection slots={{
defaultCollapseIcon={<FolderOpenIcon />} collapseIcon: FolderOpenIcon,
defaultExpandIcon={<FolderIcon />} expandIcon: FolderIcon,
expanded={expanded} endIcon: PhotoOutlined,
}}
expandedItems={expandedItems}
selectedItems={selectedItem}
onSelectedItemsChange={(event, itemId) => {
if (itemId != null && folderFullPathContainingPhotos.has(itemId)) {
setSelectedItem(itemId);
}
}}
> >
<TreeItem <TreeItem
key={root.fullPath} key={root.fullPath}
nodeId={root.fullPath} itemId={root.fullPath}
label={`${root.name} - (${root.numberOfFiles})`} label={
<>
{root.name} <Chip label={root.numberOfFiles} size="small" />
</>
}
onClick={() => navigate(root.fullPath)} onClick={() => navigate(root.fullPath)}
/> />
{root.children.length > 0 ? ( {root.children.length > 0 ? (
@ -94,7 +130,7 @@ const GenerateTreeView = ({ root }: { root: Folders }) => {
// eslint-disable-next-line react/jsx-no-useless-fragment // eslint-disable-next-line react/jsx-no-useless-fragment
<></> <></>
)} )}
</TreeView> </SimpleTreeView>
); );
}; };
@ -106,18 +142,24 @@ export const ImageGalleryDrawer = ({
}: { }: {
open: boolean; open: boolean;
drawerWidth: number; drawerWidth: number;
folders: Folders; folders: Folders | undefined;
handleDrawerToggle: () => void; handleDrawerToggle: () => void;
}) => { }) => {
const theme = useTheme(); const theme = useTheme();
const smallScreen = !useMediaQuery(smallScreenMediaQuery); const smallScreen = !useMediaQuery(smallScreenMediaQuery);
const drawerContent = ( const drawerContent =
<> folders != undefined ? (
<Toolbar sx={{ marginBottom: 3 }} /> <>
<GenerateTreeView root={folders} /> <Toolbar sx={{ marginBottom: 3 }} />
</> <GenerateTreeView root={folders} />
); </>
) : (
<>
<Toolbar sx={{ marginBottom: 3 }} />
<Typography sx={{ marginLeft: 2 }}>Loading folders...</Typography>
</>
);
return smallScreen ? ( return smallScreen ? (
<Drawer <Drawer

View File

@ -1,6 +1,4 @@
import { Pathname } from "history"; export const getDefaultExpanded = (pathname: string): string[] => {
export const getDefaultExpanded = (pathname: Pathname): string[] => {
const pathParts = []; const pathParts = [];
let curPathname = pathname.startsWith("/") ? pathname.slice(1) : pathname; let curPathname = pathname.startsWith("/") ? pathname.slice(1) : pathname;
while (curPathname.endsWith("/")) { while (curPathname.endsWith("/")) {

View File

@ -1,11 +1,10 @@
import env from "../env";
import { CircularProgress } from "@mui/material"; import { CircularProgress } from "@mui/material";
import React from "react"; import React from "react";
export const Spinner = (): JSX.Element => { export const Spinner = (): JSX.Element => {
return ( return (
<CircularProgress <CircularProgress
style={{ color: env.REACT_APP_APPBAR_COLOR ?? "#1976D2" }} style={{ color: import.meta.env.VITE_APPBAR_COLOR ?? "#1976D2" }}
size={100} size={100}
/> />
); );

View File

@ -20,6 +20,6 @@ describe("getDefaultExpanded", () => {
"should allow valid path: %s", "should allow valid path: %s",
({ pathname, expanded }) => { ({ pathname, expanded }) => {
expect(getDefaultExpanded(pathname).sort()).toEqual(expanded.sort()); expect(getDefaultExpanded(pathname).sort()).toEqual(expanded.sort());
} },
); );
}); });

View File

@ -1,4 +1,4 @@
import { Photo } from "react-photo-album"; import { Slide } from "yet-another-react-lightbox";
export interface Folders { export interface Folders {
name: string; name: string;
@ -7,6 +7,13 @@ export interface Folders {
children: Folders[]; children: Folders[];
} }
export interface ImageWithThumbnail extends Photo { export interface FolderPreview {
name: string;
fullPath: string;
numberOfFiles: number;
imagePreviewSrc: string | undefined;
}
export interface ImageWithThumbnail extends Slide {
thumbnail: string; thumbnail: string;
} }

View File

@ -1,13 +1,19 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import Box from "@mui/material/Box";
import CssBaseline from "@mui/material/CssBaseline"; import CssBaseline from "@mui/material/CssBaseline";
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)`;
@ -18,13 +24,10 @@ function ImageGalleryLayout() {
const [imagesLoaded, setImagesLoaded] = useState(false); const [imagesLoaded, setImagesLoaded] = useState(false);
const [images, setImages] = useState<ImageWithThumbnail[]>([]); const [images, setImages] = useState<ImageWithThumbnail[]>([]);
const [folders, setFolders] = useState<Folders>({ const [folders, setFolders] = useState<Folders | undefined>(undefined);
name: "Home", const [foldersPreview, setFoldersPreview] = useState<
fullPath: "/", FolderPreview[] | undefined
numberOfFiles: 0, >([]);
children: [],
});
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
@ -33,10 +36,15 @@ function ImageGalleryLayout() {
} }
useEffect(() => { useEffect(() => {
setFoldersPreview(undefined);
setImages([]); setImages([]);
setError(false); setError(false);
setImagesLoaded(false); setImagesLoaded(false);
fetch(`/images${location.pathname}`) fetch(`/images${location.pathname}`, {
headers: {
Accept: "application/json",
},
})
.then((res) => res.json()) .then((res) => res.json())
.then((data) => { .then((data) => {
if (data.images === undefined) { if (data.images === undefined) {
@ -50,10 +58,23 @@ 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(() => {
fetch("/directories") fetch("/directories", {
headers: {
Accept: "application/json",
},
})
.then((res) => res.json()) .then((res) => res.json())
.then((data) => setFolders(data)); .then((data) => setFolders(data));
}, []); }, []);
@ -82,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

@ -1,18 +1,3 @@
declare global {
// eslint-disable-next-line no-unused-vars
interface Window {
env: any;
}
}
type EnvType = {
REACT_APP_TITLE: string;
REACT_APP_APPBAR_COLOR: string;
REACT_APP_FAVICON_HREF: string | undefined;
};
const env: EnvType = { ...process.env, ...window.env };
function getTitleElement() { function getTitleElement() {
return document.getElementById("appTitle")!; return document.getElementById("appTitle")!;
} }
@ -22,13 +7,11 @@ function getFaviconElement() {
} }
export const setGalleryTitleAndFavicon = () => { export const setGalleryTitleAndFavicon = () => {
if (env.REACT_APP_FAVICON_HREF !== undefined) { if (import.meta.env.VITE_FAVICON_HREF !== undefined) {
const favicon = getFaviconElement(); const favicon = getFaviconElement();
favicon.href = env.REACT_APP_FAVICON_HREF; favicon.href = import.meta.env.VITE_FAVICON_HREF;
} }
const title = getTitleElement(); const title = getTitleElement();
title.textContent = env.REACT_APP_TITLE; title.textContent = import.meta.env.VITE_TITLE;
}; };
export default env;

View File

@ -9,14 +9,14 @@ import { setGalleryTitleAndFavicon } from "./env";
setGalleryTitleAndFavicon(); setGalleryTitleAndFavicon();
const root = ReactDOM.createRoot( const root = ReactDOM.createRoot(
document.getElementById("root") as HTMLElement document.getElementById("root") as HTMLElement,
); );
root.render( root.render(
<React.StrictMode> <React.StrictMode>
<BrowserRouter> <BrowserRouter>
<ImageGalleryLayout /> <ImageGalleryLayout />
</BrowserRouter> </BrowserRouter>
</React.StrictMode> </React.StrictMode>,
); );
// If you want to start measuring performance in your app, pass a function // If you want to start measuring performance in your app, pass a function

View File

@ -1 +0,0 @@
/// <reference types="react-scripts" />

View File

@ -1,5 +1 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes. // currently empty
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import "@testing-library/jest-dom";

View File

@ -0,0 +1,11 @@
import useMediaQuery from "@mui/material/useMediaQuery";
const breakpoints = Object.freeze([1200, 600, 300, 0]);
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

@ -0,0 +1,11 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_TITLE: string;
readonly VITE_APPBAR_COLOR: string;
readonly VITE_FAVICON_HREF: string | undefined;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

@ -1,26 +1,29 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "es5", "target": "es5",
"lib": [ "lib": [
"dom", "dom",
"dom.iterable", "dom.iterable",
"esnext" "esnext"
], ],
"allowJs": true, "allowJs": true,
"skipLibCheck": true, "skipLibCheck": true,
"esModuleInterop": true, "esModuleInterop": true,
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"strict": true, "strict": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"module": "esnext", "module": "esnext",
"moduleResolution": "node", "moduleResolution": "node",
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"noEmit": true, "noEmit": true,
"jsx": "react-jsx" "jsx": "react-jsx",
}, "types": [
"include": [ "vitest/globals"
"src" ]
] },
} "include": [
"src"
]
}

View File

@ -0,0 +1,52 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import eslint from "vite-plugin-eslint";
import viteTsconfigPaths from "vite-tsconfig-paths";
// @ts-ignore
import importMetaEnv from "@import-meta-env/unplugin";
export default defineConfig(() => {
return {
build: {
outDir: "build",
sourcemap: true,
},
plugins: [
react(),
eslint(),
viteTsconfigPaths(),
importMetaEnv.vite({ example: ".env.example" }),
],
server: {
proxy: {
// Should only match in case header accepts JSON, otherwise vite should handle it and not the proxy
// Could not find a way to do that
"/images": {
target: "http://localhost:3001",
changeOrigin: true,
secure: false,
},
"/directories": {
target: "http://localhost:3001",
changeOrigin: true,
secure: false,
},
"/folderspreview": {
target: "http://localhost:3001",
changeOrigin: true,
secure: false,
},
"/staticImages": {
target: "http://localhost:3001",
changeOrigin: true,
secure: false,
},
},
},
test: {
globals: true,
environment: "jsdom",
include: ["./**/*.test.ts", "./**/*.test.tsx"],
},
};
});

File diff suppressed because it is too large Load Diff

View File

@ -18,31 +18,30 @@
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"devDependencies": { "devDependencies": {
"@types/express": "4.17.13", "@types/express": "4.17.21",
"@types/jest": "28.1.2", "@types/jest": "29.5.12",
"@types/node": "16.11.7", "@types/node": "22.2.0",
"@types/sharp": "0.30.1", "@typescript-eslint/eslint-plugin": "8.0.1",
"@typescript-eslint/eslint-plugin": "5.18.0", "@typescript-eslint/parser": "8.0.1",
"@typescript-eslint/parser": "5.18.0", "eslint": "^8.57.0",
"eslint": "8.13.0",
"eslint-config-airbnb-base": "15.0.0", "eslint-config-airbnb-base": "15.0.0",
"eslint-config-prettier": "8.5.0", "eslint-config-prettier": "9.1.0",
"eslint-import-resolver-typescript": "2.7.1", "eslint-import-resolver-typescript": "3.6.1",
"eslint-plugin-import": "2.26.0", "eslint-plugin-import": "2.29.1",
"eslint-plugin-jest": "26.5.3", "eslint-plugin-jest": "28.8.0",
"eslint-plugin-prettier": "4.0.0", "eslint-plugin-prettier": "5.2.1",
"jest": "28.1.1", "jest": "29.7.0",
"nodemon": "2.0.15", "nodemon": "3.1.4",
"prettier": "2.6.2", "prettier": "3.3.3",
"ts-jest": "28.0.5", "ts-jest": "29.2.4",
"ts-node": "10.7.0", "ts-node": "10.9.2",
"typescript": "4.6.3" "typescript": "5.5.4"
}, },
"dependencies": { "dependencies": {
"express": "4.17.3", "express": "4.19.2",
"express-winston": "4.2.0", "express-winston": "4.2.0",
"natsort": "2.0.3", "natsort": "2.0.3",
"sharp": "0.30.3", "sharp": "0.33.4",
"winston": "3.7.2" "winston": "3.14.1"
} }
} }

View File

@ -1,46 +1,31 @@
import express from "express"; import express from "express";
import * as path from "path";
import { walk } from "./fsExtension";
import { initThumbnailsAsync } from "./thumbnails"; import { initThumbnailsAsync } from "./thumbnails";
import { publicPath } from "./paths";
import { getImages } from "./controller/images";
import { consoleLogger, expressLogger } from "./logging"; import { consoleLogger, expressLogger } from "./logging";
import { routerApi } from "./router/routerApi";
import { routerHtml } from "./router/routerHtml";
const app = express(); const app = express();
const PORT = process.env.PORT || 3001; const PORT = process.env.PORT || 3001;
const withCaching = { function checkAPICall(req: express.Request) {
maxAge: 2592000000, return !req.accepts("text/html");
setHeaders(res, _) { }
res.setHeader(
"Expires",
new Date(Date.now() + 2592000000 * 30).toUTCString()
);
},
};
app.use("/staticImages", express.static(publicPath, withCaching)); app.use(function (req, res, next) {
if (checkAPICall(req)) {
req.url = `/api${req.url}`;
} else {
req.url = `/html${req.url}`;
}
next();
});
app.use(express.static("../picture-gallery-client/build")); app.use("/api", routerApi);
app.use("/html", routerHtml);
app.use(expressLogger); app.use(expressLogger);
const imagesPath = "/images";
app.get(`${imagesPath}(/*)?`, getImages);
app.get("/directories", async (req, res) => {
res.json(await walk(""));
});
// All other GET requests not handled before will return our React app
app.get("*", (req, res) => {
res.sendFile(
path.resolve(__dirname, "../../picture-gallery-client/build/index.html")
);
});
app.listen(PORT, () => { app.listen(PORT, () => {
consoleLogger.info(`Start processing thumbnails async`); consoleLogger.info(`Start processing thumbnails async`);
initThumbnailsAsync(""); initThumbnailsAsync("");

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,48 +0,0 @@
import fs from "fs";
import * as path from "path";
import sharp from "sharp";
import { Folders } from "./models";
import { publicPath, thumbnailPath } from "./paths";
import { consoleLogger } from "./logging";
const isImageProcessable = async (filePath: string): Promise<boolean> =>
sharp(filePath)
.metadata()
.then(() => true)
.catch((err) => {
consoleLogger.error(
`Reading metadata from ${filePath} produced the following error: ${err.message}`
);
return false;
});
export const walk = async (dirPath: string): Promise<Folders> => {
const dirEnts = fs.readdirSync(path.posix.join(publicPath, dirPath), {
withFileTypes: true,
});
dirEnts.filter((f) => f.isFile());
const numberOfFiles = (
await Promise.all(
dirEnts
.filter((f) => f.isFile())
.map((f) => path.posix.join(publicPath, dirPath, f.name))
.map(isImageProcessable)
)
).filter((a) => a).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

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

View File

@ -0,0 +1,26 @@
import express from "express";
import * as path from "path";
import { publicPath } from "../paths";
export const routerHtml = express.Router();
const withCaching = {
maxAge: 2592000000,
setHeaders(res, _) {
res.setHeader(
"Expires",
new Date(Date.now() + 2592000000 * 30).toUTCString(),
);
},
};
routerHtml.use("/staticImages", express.static(publicPath, withCaching));
routerHtml.use(express.static("../picture-gallery-client/build"));
// All other GET requests not handled before will return our React app
routerHtml.get("*", (req, res) => {
res.sendFile(
path.resolve(__dirname, "../../../picture-gallery-client/build/index.html"),
);
});

View File

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

View File

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

View File

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

View File

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

View File

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View File

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

View File

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View File

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

View File

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

View File

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB