Compare commits
16 Commits
6fe5c0b241
...
024dc2ca4f
| Author | SHA1 | Date |
|---|---|---|
|
|
024dc2ca4f | |
|
|
16c6413a45 | |
|
|
9b8d4c76fb | |
|
|
02098fd2ea | |
|
|
5e860d0ea4 | |
|
|
30cdba8fd6 | |
|
|
0e28646b62 | |
|
|
77348a9323 | |
|
|
66962106d3 | |
|
|
e28475c5aa | |
|
|
0ae8a1f257 | |
|
|
a82441d671 | |
|
|
59d12418ff | |
|
|
a71a763532 | |
|
|
210cd74155 | |
|
|
5b3f77c794 |
|
|
@ -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 }}."
|
||||
10
Dockerfile
|
|
@ -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/
|
||||
WORKDIR /usr/src/app/picture-gallery-client
|
||||
|
|
@ -10,9 +10,11 @@ RUN mkdir built && \
|
|||
mv build built && \
|
||||
mv package.minimize.docker.json built/package.json && \
|
||||
cp package-lock.json built && \
|
||||
cp .env.example built && \
|
||||
cp .env built && \
|
||||
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/
|
||||
WORKDIR /usr/src/app/picture-gallery-server
|
||||
|
|
@ -23,7 +25,9 @@ RUN npm run server:build
|
|||
RUN mkdir 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=server-builder --chown=node:node /usr/src/app/picture-gallery-server/built /usr/src/app/picture-gallery-server/
|
||||
|
|
|
|||
64
README.md
|
|
@ -1,31 +1,35 @@
|
|||
# Simple Picture Gallery
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Docker
|
||||
|
||||
```shell
|
||||
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
|
||||
```
|
||||
|
||||
### Customization
|
||||
|
||||
Create an environment file `.env`:
|
||||
|
||||
```properties
|
||||
REACT_APP_TITLE=My Gallery
|
||||
REACT_APP_APPBAR_COLOR=#F8AB2D
|
||||
```
|
||||
|
||||
Other properties:
|
||||
|
||||
```properties
|
||||
REACT_APP_FAVICON_HREF=<URL to your favicon>
|
||||
```
|
||||
|
||||
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
|
||||
# Simple Picture Gallery
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Information
|
||||
|
||||
Folders should only contain images and folders. Folders should not contain any other files.
|
||||
|
||||
### Docker
|
||||
|
||||
```shell
|
||||
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
|
||||
```
|
||||
|
||||
### Customization
|
||||
|
||||
Create an environment file `.env`:
|
||||
|
||||
```properties
|
||||
VITE_TITLE=My Gallery
|
||||
VITE_APPBAR_COLOR=#F8AB2D
|
||||
```
|
||||
|
||||
Other properties:
|
||||
|
||||
```properties
|
||||
VITE_FAVICON_HREF=<URL to your favicon>
|
||||
```
|
||||
|
||||
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
|
||||
```
|
||||
|
|
@ -2,7 +2,8 @@
|
|||
"name": "simple-picture-gallery",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"concurrently": "7.2.1"
|
||||
"concurrently": "7.2.1",
|
||||
"npm-check-updates": "^17.0.6"
|
||||
},
|
||||
"scripts": {
|
||||
"install-all": "npm i && concurrently npm:install:client npm:install:server",
|
||||
|
|
@ -11,6 +12,8 @@
|
|||
"start-all": "concurrently npm:run:client npm:run:server",
|
||||
"run:client": "npm run --prefix picture-gallery-client client: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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
# Set user specific variables
|
||||
REACT_APP_TITLE=Simple Picture Gallery
|
||||
REACT_APP_APPBAR_COLOR=#1976D2
|
||||
VITE_TITLE=Simple Picture Gallery
|
||||
VITE_APPBAR_COLOR=#1976D2
|
||||
VITE_FAVICON_HREF=/favicon.ico
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
# Set user specific variables
|
||||
VITE_TITLE=
|
||||
VITE_APPBAR_COLOR=
|
||||
VITE_FAVICON_HREF=
|
||||
|
|
@ -1,8 +1,7 @@
|
|||
{
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es2021": true,
|
||||
"jest/globals": true
|
||||
"es2021": true
|
||||
},
|
||||
"extends": [
|
||||
"plugin:react/recommended",
|
||||
|
|
@ -19,8 +18,7 @@
|
|||
},
|
||||
"plugins": [
|
||||
"react",
|
||||
"@typescript-eslint",
|
||||
"jest"
|
||||
"@typescript-eslint"
|
||||
],
|
||||
"ignorePatterns": [
|
||||
"**/*.css"
|
||||
|
|
@ -55,6 +53,9 @@
|
|||
"import/resolver": {
|
||||
"typescript": {
|
||||
}
|
||||
},
|
||||
"react": {
|
||||
"version": "detect"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,28 +2,19 @@
|
|||
<html lang="en">
|
||||
<head>
|
||||
<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="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
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
|
||||
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||
-->
|
||||
<link rel="manifest" href="%PUBLIC_URL%/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`.
|
||||
-->
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<title id="appTitle">Simple Picture Gallery</title>
|
||||
</head>
|
||||
<body>
|
||||
|
|
@ -39,6 +30,9 @@
|
|||
To begin the development, run `npm start` or `yarn start`.
|
||||
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>
|
||||
</html>
|
||||
|
|
@ -3,52 +3,65 @@
|
|||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"proxy": "http://localhost:3001",
|
||||
"dependencies": {
|
||||
"@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"
|
||||
},
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"format": "prettier --write \"**/*.+(ts|tsx)\"",
|
||||
"format:check": "prettier --check \"**/*.+(ts|tsx)\"",
|
||||
"lint": "eslint src/**",
|
||||
"lint:fix": "eslint --fix src/**",
|
||||
"client:build": "react-scripts build && npm run set-environment",
|
||||
"client:eject": "react-scripts eject",
|
||||
"client:build": "vite build",
|
||||
"client:run": "npm run client:build && npm run client:start",
|
||||
"client:start": "react-scripts start",
|
||||
"client:test": "react-scripts test",
|
||||
"set-environment": "npx react-inject-env set",
|
||||
"test": "jest"
|
||||
"client:start": "vite",
|
||||
"set-environment": "npx import-meta-env -x .env.example",
|
||||
"client:test": "vitest",
|
||||
"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": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
"react-app"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
|
|
@ -62,15 +75,5 @@
|
|||
"last 1 firefox 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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@
|
|||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"react-inject-env": "2.1.0"
|
||||
"@import-meta-env/cli": "^0.6.9"
|
||||
},
|
||||
"scripts": {
|
||||
"set-environment": "npx react-inject-env set"
|
||||
"set-environment": "npx import-meta-env -x .env.example"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -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"}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,38 +1,33 @@
|
|||
import PhotoAlbum from "react-photo-album";
|
||||
import React, { useState } from "react";
|
||||
import { Image } from "./Image";
|
||||
import { ImageWithThumbnail } from "./models";
|
||||
import { Lightbox } from "yet-another-react-lightbox";
|
||||
import "yet-another-react-lightbox/styles.css";
|
||||
|
||||
import { Fullscreen } from "yet-another-react-lightbox/plugins/fullscreen";
|
||||
import { Slideshow } from "yet-another-react-lightbox/plugins/slideshow";
|
||||
import { Thumbnails } from "yet-another-react-lightbox/plugins/thumbnails";
|
||||
import Fullscreen from "yet-another-react-lightbox/plugins/fullscreen";
|
||||
import Slideshow from "yet-another-react-lightbox/plugins/slideshow";
|
||||
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 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 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 (
|
||||
<>
|
||||
<PhotoAlbum
|
||||
layout="masonry"
|
||||
photos={images}
|
||||
renderPhoto={Image}
|
||||
onClick={(event, photo, index) => setIndex(index)}
|
||||
/>
|
||||
<ImageList variant="masonry" cols={columns} gap={8}>
|
||||
{images.map((item, index) => (
|
||||
<ImageListItem key={item.thumbnail}>
|
||||
<img
|
||||
src={item.thumbnail}
|
||||
loading="lazy"
|
||||
onClick={() => setIndex(index)}
|
||||
/>
|
||||
</ImageListItem>
|
||||
))}
|
||||
</ImageList>
|
||||
<Lightbox
|
||||
slides={images}
|
||||
open={index >= 0}
|
||||
|
|
@ -49,6 +44,4 @@ function ImageGallery({ images }: { images: ImageWithThumbnail[] }) {
|
|||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ImageGallery;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import Toolbar from "@mui/material/Toolbar";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import React from "react";
|
||||
import env from "../env";
|
||||
import AppBar from "@mui/material/AppBar";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
import { IconButton } from "@mui/material";
|
||||
|
|
@ -21,7 +20,9 @@ export const ImageGalleryAppBar = ({
|
|||
<AppBar
|
||||
position="fixed"
|
||||
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>
|
||||
{smallScreen && (
|
||||
|
|
@ -36,7 +37,7 @@ export const ImageGalleryAppBar = ({
|
|||
</IconButton>
|
||||
)}
|
||||
<Typography variant="h6" noWrap component="div">
|
||||
{env.REACT_APP_TITLE ?? "Simple Picture Gallery"}
|
||||
{import.meta.env.VITE_TITLE ?? "Simple Picture Gallery"}
|
||||
</Typography>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
|
|
|
|||
|
|
@ -1,31 +1,39 @@
|
|||
import Drawer from "@mui/material/Drawer";
|
||||
import FolderIcon from "@mui/icons-material/Folder";
|
||||
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 React, { useState } from "react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { Folders } from "./models";
|
||||
import Toolbar from "@mui/material/Toolbar";
|
||||
import { useTheme } from "@mui/material";
|
||||
import { Chip, useTheme } from "@mui/material";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
import { smallScreenMediaQuery } from "../ImageGalleryLayout";
|
||||
import { getDefaultExpanded } from "./PathToExpaned";
|
||||
import Typography from "@mui/material/Typography";
|
||||
|
||||
function generateTreeViewChildren(
|
||||
folders: Folders[],
|
||||
navigateAndToggleExpand: (_path: string, _navigationAllowed: boolean) => void
|
||||
navigateAndToggleExpand: (_path: string, _navigationAllowed: boolean) => void,
|
||||
) {
|
||||
return (
|
||||
<>
|
||||
{folders.map((f) => {
|
||||
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;
|
||||
if (f.children.length === 0) {
|
||||
return (
|
||||
<TreeItem
|
||||
key={f.fullPath}
|
||||
nodeId={f.fullPath}
|
||||
itemId={f.fullPath}
|
||||
label={label}
|
||||
onClick={() =>
|
||||
navigateAndToggleExpand(f.fullPath, containsImages)
|
||||
|
|
@ -36,7 +44,7 @@ function generateTreeViewChildren(
|
|||
return (
|
||||
<TreeItem
|
||||
key={f.fullPath}
|
||||
nodeId={f.fullPath}
|
||||
itemId={f.fullPath}
|
||||
label={label}
|
||||
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 location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [expanded, setExpanded] = useState<string[]>(
|
||||
getDefaultExpanded(location.pathname)
|
||||
const folderFullPathContainingPhotos = useMemo(
|
||||
() => calcFolderWithItem(root, new Set()),
|
||||
[root],
|
||||
);
|
||||
const [expandedItems, setExpandedItems] = useState<string[]>(
|
||||
getDefaultExpanded(location.pathname),
|
||||
);
|
||||
const [selectedItem, setSelectedItem] = useState<string>(location.pathname);
|
||||
|
||||
const toggleExpanded = (path: string) => {
|
||||
if (expanded.includes(path)) {
|
||||
setExpanded(expanded.filter((p) => p !== path));
|
||||
if (expandedItems.includes(path)) {
|
||||
setExpandedItems(expandedItems.filter((p) => p !== path));
|
||||
} else {
|
||||
setExpanded([path, ...expanded]);
|
||||
setExpandedItems([path, ...expandedItems]);
|
||||
}
|
||||
};
|
||||
|
||||
const navigateAndToggleExpand = (
|
||||
path: string,
|
||||
navigationAllowed: boolean
|
||||
navigationAllowed: boolean,
|
||||
) => {
|
||||
if (!navigationAllowed || location.pathname === path) {
|
||||
toggleExpanded(path);
|
||||
|
|
@ -76,16 +100,28 @@ const GenerateTreeView = ({ root }: { root: Folders }) => {
|
|||
};
|
||||
|
||||
return (
|
||||
<TreeView
|
||||
disableSelection
|
||||
defaultCollapseIcon={<FolderOpenIcon />}
|
||||
defaultExpandIcon={<FolderIcon />}
|
||||
expanded={expanded}
|
||||
<SimpleTreeView
|
||||
slots={{
|
||||
collapseIcon: FolderOpenIcon,
|
||||
expandIcon: FolderIcon,
|
||||
endIcon: PhotoOutlined,
|
||||
}}
|
||||
expandedItems={expandedItems}
|
||||
selectedItems={selectedItem}
|
||||
onSelectedItemsChange={(event, itemId) => {
|
||||
if (itemId != null && folderFullPathContainingPhotos.has(itemId)) {
|
||||
setSelectedItem(itemId);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TreeItem
|
||||
key={root.fullPath}
|
||||
nodeId={root.fullPath}
|
||||
label={`${root.name} - (${root.numberOfFiles})`}
|
||||
itemId={root.fullPath}
|
||||
label={
|
||||
<>
|
||||
{root.name} <Chip label={root.numberOfFiles} size="small" />
|
||||
</>
|
||||
}
|
||||
onClick={() => navigate(root.fullPath)}
|
||||
/>
|
||||
{root.children.length > 0 ? (
|
||||
|
|
@ -94,7 +130,7 @@ const GenerateTreeView = ({ root }: { root: Folders }) => {
|
|||
// eslint-disable-next-line react/jsx-no-useless-fragment
|
||||
<></>
|
||||
)}
|
||||
</TreeView>
|
||||
</SimpleTreeView>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -106,18 +142,24 @@ export const ImageGalleryDrawer = ({
|
|||
}: {
|
||||
open: boolean;
|
||||
drawerWidth: number;
|
||||
folders: Folders;
|
||||
folders: Folders | undefined;
|
||||
handleDrawerToggle: () => void;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const smallScreen = !useMediaQuery(smallScreenMediaQuery);
|
||||
|
||||
const drawerContent = (
|
||||
<>
|
||||
<Toolbar sx={{ marginBottom: 3 }} />
|
||||
<GenerateTreeView root={folders} />
|
||||
</>
|
||||
);
|
||||
const drawerContent =
|
||||
folders != undefined ? (
|
||||
<>
|
||||
<Toolbar sx={{ marginBottom: 3 }} />
|
||||
<GenerateTreeView root={folders} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Toolbar sx={{ marginBottom: 3 }} />
|
||||
<Typography sx={{ marginLeft: 2 }}>Loading folders...</Typography>
|
||||
</>
|
||||
);
|
||||
|
||||
return smallScreen ? (
|
||||
<Drawer
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
import { Pathname } from "history";
|
||||
|
||||
export const getDefaultExpanded = (pathname: Pathname): string[] => {
|
||||
export const getDefaultExpanded = (pathname: string): string[] => {
|
||||
const pathParts = [];
|
||||
let curPathname = pathname.startsWith("/") ? pathname.slice(1) : pathname;
|
||||
while (curPathname.endsWith("/")) {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
import env from "../env";
|
||||
import { CircularProgress } from "@mui/material";
|
||||
import React from "react";
|
||||
|
||||
export const Spinner = (): JSX.Element => {
|
||||
return (
|
||||
<CircularProgress
|
||||
style={{ color: env.REACT_APP_APPBAR_COLOR ?? "#1976D2" }}
|
||||
style={{ color: import.meta.env.VITE_APPBAR_COLOR ?? "#1976D2" }}
|
||||
size={100}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -20,6 +20,6 @@ describe("getDefaultExpanded", () => {
|
|||
"should allow valid path: %s",
|
||||
({ pathname, expanded }) => {
|
||||
expect(getDefaultExpanded(pathname).sort()).toEqual(expanded.sort());
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Photo } from "react-photo-album";
|
||||
import { Slide } from "yet-another-react-lightbox";
|
||||
|
||||
export interface Folders {
|
||||
name: string;
|
||||
|
|
@ -7,6 +7,13 @@ export interface 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,19 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import Box from "@mui/material/Box";
|
||||
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)`;
|
||||
|
|
@ -18,13 +24,10 @@ function ImageGalleryLayout() {
|
|||
const [imagesLoaded, setImagesLoaded] = useState(false);
|
||||
const [images, setImages] = useState<ImageWithThumbnail[]>([]);
|
||||
|
||||
const [folders, setFolders] = useState<Folders>({
|
||||
name: "Home",
|
||||
fullPath: "/",
|
||||
numberOfFiles: 0,
|
||||
children: [],
|
||||
});
|
||||
|
||||
const [folders, setFolders] = useState<Folders | undefined>(undefined);
|
||||
const [foldersPreview, setFoldersPreview] = useState<
|
||||
FolderPreview[] | undefined
|
||||
>([]);
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
|
|
@ -33,10 +36,15 @@ function ImageGalleryLayout() {
|
|||
}
|
||||
|
||||
useEffect(() => {
|
||||
setFoldersPreview(undefined);
|
||||
setImages([]);
|
||||
setError(false);
|
||||
setImagesLoaded(false);
|
||||
fetch(`/images${location.pathname}`)
|
||||
fetch(`/images${location.pathname}`, {
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.images === undefined) {
|
||||
|
|
@ -50,10 +58,23 @@ function ImageGalleryLayout() {
|
|||
setImagesLoaded(true);
|
||||
}
|
||||
});
|
||||
fetch(`/folderspreview${location.pathname}`, {
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
setFoldersPreview(data);
|
||||
});
|
||||
}, [location.pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/directories")
|
||||
fetch("/directories", {
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((data) => setFolders(data));
|
||||
}, []);
|
||||
|
|
@ -82,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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
return document.getElementById("appTitle")!;
|
||||
}
|
||||
|
|
@ -22,13 +7,11 @@ function getFaviconElement() {
|
|||
}
|
||||
|
||||
export const setGalleryTitleAndFavicon = () => {
|
||||
if (env.REACT_APP_FAVICON_HREF !== undefined) {
|
||||
if (import.meta.env.VITE_FAVICON_HREF !== undefined) {
|
||||
const favicon = getFaviconElement();
|
||||
favicon.href = env.REACT_APP_FAVICON_HREF;
|
||||
favicon.href = import.meta.env.VITE_FAVICON_HREF;
|
||||
}
|
||||
|
||||
const title = getTitleElement();
|
||||
title.textContent = env.REACT_APP_TITLE;
|
||||
title.textContent = import.meta.env.VITE_TITLE;
|
||||
};
|
||||
|
||||
export default env;
|
||||
|
|
|
|||
|
|
@ -9,14 +9,14 @@ import { setGalleryTitleAndFavicon } from "./env";
|
|||
setGalleryTitleAndFavicon();
|
||||
|
||||
const root = ReactDOM.createRoot(
|
||||
document.getElementById("root") as HTMLElement
|
||||
document.getElementById("root") as HTMLElement,
|
||||
);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<ImageGalleryLayout />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
// If you want to start measuring performance in your app, pass a function
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
/// <reference types="react-scripts" />
|
||||
|
|
@ -1,5 +1 @@
|
|||
// jest-dom adds custom jest matchers for asserting on DOM nodes.
|
||||
// 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";
|
||||
// currently empty
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -1,26 +1,29 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"types": [
|
||||
"vitest/globals"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
@ -18,31 +18,30 @@
|
|||
"author": "",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@types/express": "4.17.13",
|
||||
"@types/jest": "28.1.2",
|
||||
"@types/node": "16.11.7",
|
||||
"@types/sharp": "0.30.1",
|
||||
"@typescript-eslint/eslint-plugin": "5.18.0",
|
||||
"@typescript-eslint/parser": "5.18.0",
|
||||
"eslint": "8.13.0",
|
||||
"@types/express": "4.17.21",
|
||||
"@types/jest": "29.5.12",
|
||||
"@types/node": "22.2.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.0.1",
|
||||
"@typescript-eslint/parser": "8.0.1",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-airbnb-base": "15.0.0",
|
||||
"eslint-config-prettier": "8.5.0",
|
||||
"eslint-import-resolver-typescript": "2.7.1",
|
||||
"eslint-plugin-import": "2.26.0",
|
||||
"eslint-plugin-jest": "26.5.3",
|
||||
"eslint-plugin-prettier": "4.0.0",
|
||||
"jest": "28.1.1",
|
||||
"nodemon": "2.0.15",
|
||||
"prettier": "2.6.2",
|
||||
"ts-jest": "28.0.5",
|
||||
"ts-node": "10.7.0",
|
||||
"typescript": "4.6.3"
|
||||
"eslint-config-prettier": "9.1.0",
|
||||
"eslint-import-resolver-typescript": "3.6.1",
|
||||
"eslint-plugin-import": "2.29.1",
|
||||
"eslint-plugin-jest": "28.8.0",
|
||||
"eslint-plugin-prettier": "5.2.1",
|
||||
"jest": "29.7.0",
|
||||
"nodemon": "3.1.4",
|
||||
"prettier": "3.3.3",
|
||||
"ts-jest": "29.2.4",
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "5.5.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "4.17.3",
|
||||
"express": "4.19.2",
|
||||
"express-winston": "4.2.0",
|
||||
"natsort": "2.0.3",
|
||||
"sharp": "0.30.3",
|
||||
"winston": "3.7.2"
|
||||
"sharp": "0.33.4",
|
||||
"winston": "3.14.1"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,46 +1,31 @@
|
|||
import express from "express";
|
||||
import * as path from "path";
|
||||
import { walk } from "./fsExtension";
|
||||
import { initThumbnailsAsync } from "./thumbnails";
|
||||
import { publicPath } from "./paths";
|
||||
import { getImages } from "./controller/images";
|
||||
import { consoleLogger, expressLogger } from "./logging";
|
||||
import { routerApi } from "./router/routerApi";
|
||||
import { routerHtml } from "./router/routerHtml";
|
||||
|
||||
const app = express();
|
||||
|
||||
const PORT = process.env.PORT || 3001;
|
||||
|
||||
const withCaching = {
|
||||
maxAge: 2592000000,
|
||||
setHeaders(res, _) {
|
||||
res.setHeader(
|
||||
"Expires",
|
||||
new Date(Date.now() + 2592000000 * 30).toUTCString()
|
||||
);
|
||||
},
|
||||
};
|
||||
function checkAPICall(req: express.Request) {
|
||||
return !req.accepts("text/html");
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
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, () => {
|
||||
consoleLogger.info(`Start processing thumbnails async`);
|
||||
initThumbnailsAsync("");
|
||||
|
|
|
|||
|
|
@ -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,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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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"),
|
||||
);
|
||||
});
|
||||
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |