Compare commits

..

No commits in common. "024dc2ca4fdbc21dd14197cc5e744f7e5924450d" and "6fe5c0b2419ce2f1684472263cb54362768fc8e3" have entirely different histories.

50 changed files with 32670 additions and 10110 deletions

View File

@ -1,19 +0,0 @@
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:22.2.0-alpine as client-builder
FROM node:16.14.2-alpine as client-builder
COPY picture-gallery-client/package*.json /usr/src/app/picture-gallery-client/
WORKDIR /usr/src/app/picture-gallery-client
@ -10,11 +10,9 @@ 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:22.2.0-alpine as server-builder
FROM node:16.14.2-alpine as server-builder
COPY picture-gallery-server/package*.json /usr/src/app/picture-gallery-server/
WORKDIR /usr/src/app/picture-gallery-server
@ -25,9 +23,7 @@ RUN npm run server:build
RUN mkdir built && \
mv dist node_modules built
FROM node:22.2.0-alpine
ENV NODE_ENV=production
FROM node:16.14.2-alpine
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,35 +1,31 @@
# 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
# 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
```

View File

@ -2,8 +2,7 @@
"name": "simple-picture-gallery",
"version": "1.0.0",
"dependencies": {
"concurrently": "7.2.1",
"npm-check-updates": "^17.0.6"
"concurrently": "7.2.1"
},
"scripts": {
"install-all": "npm i && concurrently npm:install:client npm:install:server",
@ -12,8 +11,6 @@
"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,4 +1,3 @@
# Set user specific variables
VITE_TITLE=Simple Picture Gallery
VITE_APPBAR_COLOR=#1976D2
VITE_FAVICON_HREF=/favicon.ico
REACT_APP_TITLE=Simple Picture Gallery
REACT_APP_APPBAR_COLOR=#1976D2

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -3,65 +3,52 @@
"version": "0.1.0",
"private": true,
"proxy": "http://localhost:3001",
"type": "module",
"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"
},
"scripts": {
"format": "prettier --write \"**/*.+(ts|tsx)\"",
"format:check": "prettier --check \"**/*.+(ts|tsx)\"",
"lint": "eslint src/**",
"lint:fix": "eslint --fix src/**",
"client:build": "vite build",
"client:build": "react-scripts build && npm run set-environment",
"client:eject": "react-scripts eject",
"client:run": "npm run client:build && npm run client:start",
"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"
"client:start": "react-scripts start",
"client:test": "react-scripts test",
"set-environment": "npx react-inject-env set",
"test": "jest"
},
"eslintConfig": {
"extends": [
"react-app"
"react-app",
"react-app/jest"
]
},
"browserslist": {
@ -75,5 +62,15 @@
"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": {
"@import-meta-env/cli": "^0.6.9"
"react-inject-env": "2.1.0"
},
"scripts": {
"set-environment": "npx import-meta-env -x .env.example"
"set-environment": "npx react-inject-env set"
}
}

View File

@ -2,19 +2,28 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" id="favicon" href="/favicon.ico" />
<link rel="icon" id="favicon" href="%PUBLIC_URL%/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="/logo192.png" />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/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="/manifest.json" />
<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`.
-->
<title id="appTitle">Simple Picture Gallery</title>
</head>
<body>
@ -30,9 +39,6 @@
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
<script>
globalThis.import_meta_env = JSON.parse('"import_meta_env_placeholder"');
</script>
<script type="module" src="/src/index.tsx"></script>
<script src='%PUBLIC_URL%/env.js'></script>
</body>
</html>

View File

@ -1,74 +0,0 @@
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

@ -0,0 +1,22 @@
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,33 +1,38 @@
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 { ImageList, ImageListItem } from "@mui/material";
import { useColumns } from "../util/responsive";
import { Zoom } from "yet-another-react-lightbox/plugins/zoom";
export const ImageGallery = ({ images }: { images: ImageWithThumbnail[] }) => {
function 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 (
<>
<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>
<PhotoAlbum
layout="masonry"
photos={images}
renderPhoto={Image}
onClick={(event, photo, index) => setIndex(index)}
/>
<Lightbox
slides={images}
open={index >= 0}
@ -44,4 +49,6 @@ export const ImageGallery = ({ images }: { images: ImageWithThumbnail[] }) => {
/>
</>
);
};
}
export default ImageGallery;

View File

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

View File

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

View File

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

View File

@ -1,10 +1,11 @@
import env from "../env";
import { CircularProgress } from "@mui/material";
import React from "react";
export const Spinner = (): JSX.Element => {
return (
<CircularProgress
style={{ color: import.meta.env.VITE_APPBAR_COLOR ?? "#1976D2" }}
style={{ color: env.REACT_APP_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 { Slide } from "yet-another-react-lightbox";
import { Photo } from "react-photo-album";
export interface Folders {
name: string;
@ -7,13 +7,6 @@ export interface Folders {
children: Folders[];
}
export interface FolderPreview {
name: string;
fullPath: string;
numberOfFiles: number;
imagePreviewSrc: string | undefined;
}
export interface ImageWithThumbnail extends Slide {
export interface ImageWithThumbnail extends Photo {
thumbnail: string;
}

View File

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

View File

@ -1,3 +1,18 @@
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")!;
}
@ -7,11 +22,13 @@ function getFaviconElement() {
}
export const setGalleryTitleAndFavicon = () => {
if (import.meta.env.VITE_FAVICON_HREF !== undefined) {
if (env.REACT_APP_FAVICON_HREF !== undefined) {
const favicon = getFaviconElement();
favicon.href = import.meta.env.VITE_FAVICON_HREF;
favicon.href = env.REACT_APP_FAVICON_HREF;
}
const title = getTitleElement();
title.textContent = import.meta.env.VITE_TITLE;
title.textContent = env.REACT_APP_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

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

View File

@ -1 +1,5 @@
// currently empty
// 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";

View File

@ -1,11 +0,0 @@
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

@ -1,11 +0,0 @@
/// <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,29 +1,26 @@
{
"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"
]
}
{
"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"
]
}

View File

@ -1,52 +0,0 @@
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,30 +18,31 @@
"author": "",
"license": "ISC",
"devDependencies": {
"@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",
"@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",
"eslint-config-airbnb-base": "15.0.0",
"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"
"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"
},
"dependencies": {
"express": "4.19.2",
"express": "4.17.3",
"express-winston": "4.2.0",
"natsort": "2.0.3",
"sharp": "0.33.4",
"winston": "3.14.1"
"sharp": "0.30.3",
"winston": "3.7.2"
}
}

View File

@ -1,31 +1,46 @@
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;
function checkAPICall(req: express.Request) {
return !req.accepts("text/html");
}
const withCaching = {
maxAge: 2592000000,
setHeaders(res, _) {
res.setHeader(
"Expires",
new Date(Date.now() + 2592000000 * 30).toUTCString()
);
},
};
app.use(function (req, res, next) {
if (checkAPICall(req)) {
req.url = `/api${req.url}`;
} else {
req.url = `/html${req.url}`;
}
next();
});
app.use("/staticImages", express.static(publicPath, withCaching));
app.use("/api", routerApi);
app.use("/html", routerHtml);
app.use(express.static("../picture-gallery-client/build"));
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

@ -1,29 +0,0 @@
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

@ -1,95 +0,0 @@
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,29 +3,48 @@ import express from "express";
import sharp from "sharp";
import path from "path";
import natsort from "natsort";
import { publicPath } from "../paths";
import { publicPath, thumbnailPath, thumbnailPublicPath } 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>({
@ -39,7 +58,7 @@ const toImage = (
const getImagesToBeLoaded = (
dirents: Dirent[],
thumbnails: string[],
requestedPath: string,
requestedPath: string
): Promise<Image | void>[] =>
dirents
.filter((f) => f.isFile())
@ -54,22 +73,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);
@ -84,7 +103,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

@ -0,0 +1,48 @@
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,13 +16,6 @@ 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

@ -1,13 +0,0 @@
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

@ -1,26 +0,0 @@
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