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/
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/

View File

@ -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
```

View File

@ -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"
}
}

View File

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

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": {
"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"
}
}
}

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -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"
}
}

View File

@ -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"
}
}

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 { 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;
};

View File

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

View File

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

View File

@ -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("/")) {

View File

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

View File

@ -20,6 +20,6 @@ describe("getDefaultExpanded", () => {
"should allow valid path: %s",
({ pathname, expanded }) => {
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 {
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;
}

View File

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

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() {
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;

View File

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

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.
// 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

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": {
"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"
]
}

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": "",
"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"
}
}

View File

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

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

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[];
}
export interface FolderPreview {
name: string;
fullPath: string;
numberOfFiles: number;
imagePreviewSrc: string | undefined;
}
export const a = <T>(v: T): T => {
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