Initial commit

This commit is contained in:
2024-04-17 22:54:13 +02:00
commit a8fdb455f4
162 changed files with 49158 additions and 0 deletions
Executable
+1
View File
@@ -0,0 +1 @@
REACT_APP_API_URL=http://localhost:9001
+27
View File
@@ -0,0 +1,27 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Temporary env files
/public/env-config.js
env-config.js
+26
View File
@@ -0,0 +1,26 @@
FROM nginx:1.15.2-alpine
# Nginx config
RUN rm -rf /etc/nginx/conf.d
COPY ./conf /etc/nginx
# Static build
COPY ./build /usr/share/nginx/html/
# Default port exposure
EXPOSE 80
# Copy .env file and shell script to container
WORKDIR /usr/share/nginx/html
COPY scripts/env.sh .
COPY .env .
# Add bash
RUN apk add --no-cache bash
# Make our shell script executable
RUN chmod +x env.sh
# Start Nginx server
CMD ["/bin/bash", "-c", "/usr/share/nginx/html/env.sh && nginx -g \"daemon off;\""]
+13
View File
@@ -0,0 +1,13 @@
server {
listen 80;
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
expires -1; # Set it to different value depending on your standard requirements
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}
+24
View File
@@ -0,0 +1,24 @@
gzip on;
gzip_http_version 1.0;
gzip_comp_level 5; # 1-9
gzip_min_length 256;
gzip_proxied any;
gzip_vary on;
# MIME-types
gzip_types
application/atom+xml
application/javascript
application/json
application/rss+xml
application/vnd.ms-fontobject
application/x-font-ttf
application/x-web-app-manifest+json
application/xhtml+xml
application/xml
font/opentype
image/svg+xml
image/x-icon
text/css
text/plain
text/x-component;
+17873
View File
File diff suppressed because it is too large Load Diff
+47
View File
@@ -0,0 +1,47 @@
{
"name": "public-app",
"version": "0.1.0",
"private": true,
"dependencies": {
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
"@fontsource/roboto": "^5.0.8",
"@mui/icons-material": "^5.14.19",
"@mui/material": "^5.14.19",
"keycloak-js": "^23.0.0",
"oidc-react": "^3.2.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.20.0",
"react-scripts": "5.0.1",
"sass": "^1.69.5"
},
"scripts": {
"dev": "npm run start",
"start": "scripts/env.sh ./public/env-config.js && PORT=3006 react-scripts start",
"build": "react-scripts build",
"eject": "react-scripts eject",
"docker-build": "./scripts/docker-build.sh",
"docker-run": "./scripts/docker-run.sh"
},
"eslintConfig": {
"extends": [
"react-app"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"@babel/plugin-proposal-private-property-in-object": "^7.21.11"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

+45
View File
@@ -0,0 +1,45 @@
<!DOCTYPE html>
<html lang="en">
<head>
<script src="%PUBLIC_URL%/env-config.js"></script>
<meta charset="utf-8" />
<link rel="icon" 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="Web site created using create-react-app"
/>
<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="%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>Photos</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

+25
View File
@@ -0,0 +1,25 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}
+3
View File
@@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:
+1
View File
@@ -0,0 +1 @@
docker build . -t photos-client
+9
View File
@@ -0,0 +1,9 @@
#/bin/bash
image_name=photos-client
container_name=photos-client
docker stop $container_name
docker rm $container_name
docker run -d -p 3000:80 -e REACT_APP_API_URL=https://photos-api.novox.be --name $container_name $image_name
+32
View File
@@ -0,0 +1,32 @@
#!/bin/bash
envConfigFile=${1:-./env-config.js}
# Recreate config file
rm -rf $envConfigFile
touch $envConfigFile
# Add assignment
echo "window._env_ = {" >> $envConfigFile
# Read each line in .env file
# Each line represents key=value pairs
while read -r line || [[ -n "$line" ]];
do
# Split env variables by character `=`
if printf '%s\n' "$line" | grep -q -e '='; then
varname=$(printf '%s\n' "$line" | sed -e 's/=.*//')
varvalue=$(printf '%s\n' "$line" | sed -e 's/^[^=]*=//')
fi
# Read value of current variable if exists as Environment variable
value=$(printf '%s\n' "${!varname}")
# Otherwise use value from .env file
[[ -z $value ]] && value=${varvalue}
# Append configuration property to JS file
echo " $varname: \"$value\"," >> $envConfigFile
done < .env
echo "}" >> $envConfigFile
+53
View File
@@ -0,0 +1,53 @@
import {createAnimation} from "../utils/animation";
const sync = createAnimation(
"SyncLoader",
`33% {transform: translateY(10px)}
66% {transform: translateY(-10px)}
100% {transform: translateY(0)}`,
"sync"
);
function Loader({
loading = false,
color = "#000000",
speedMultiplier = 1,
cssOverride = {},
size = 15,
margin = 2,
...additionalprops
}) {
const wrapper = {
display: "inherit",
...cssOverride,
};
const style = (i) => {
return {
backgroundColor: color,
width: size,
height: size,
margin: margin,
borderRadius: "100%",
display: "inline-block",
animation: `${sync} ${0.6 / speedMultiplier}s ${i * 0.07}s infinite ease-in-out`,
animationFillMode: "both",
};
};
if (!loading) {
return null;
}
return (
<div id="page-loader">
<span style={wrapper} {...additionalprops}>
<span style={style(1)}/>
<span style={style(2)}/>
<span style={style(3)}/>
</span>
</div>
);
}
export default Loader;
View File
@@ -0,0 +1,128 @@
import * as React from "react";
import {createRef, useEffect, useState} from "react";
import "./photo-viewer.scss"
import {ArrowLeft, ArrowRight} from "@mui/icons-material";
import {useCurrentInstance} from "../../services/current-instance/use-current-instance";
export function PhotoViewer() {
const {selectedAlbum, selectedPhotoIndex, setSelectedPhotoIndex} = useCurrentInstance();
const [touchStartX, setTouchStartX] = useState();
const [offset, setOffset] = useState(0);
const elPhotoViewer = createRef();
const hasNext = selectedPhotoIndex < (selectedAlbum?.photos.length || 0) - 1;
const hasPrevious = selectedPhotoIndex > 0;
useEffect(() => {
if (elPhotoViewer.current) {
elPhotoViewer.current.focus();
}
}, [elPhotoViewer]);
const handleClick = (e) => {
if (e.target === elPhotoViewer.current) {
setSelectedPhotoIndex(-1);
}
}
const handleGoPrevious = () => {
if (hasPrevious) {
setSelectedPhotoIndex(selectedPhotoIndex - 1);
}
}
const handleGoNext = () => {
if (hasNext) {
setSelectedPhotoIndex(selectedPhotoIndex + 1);
}
}
const handleKeyUp = (e) => {
switch (e.code) {
case "ArrowLeft":
handleGoPrevious();
break;
case "ArrowRight":
handleGoNext();
break;
case "Escape":
setSelectedPhotoIndex(-1);
break;
default:
break;
}
}
const handleTouchStart = (/** TouchEvent **/e) => {
if (e.touches.length === 1) {
//just one finger touched
setTouchStartX(e.touches.item(0).clientX);
} else {
//a second finger hit the screen, abort the touch
setTouchStartX(null);
}
}
const handleTouchMove = (/** TouchEvent **/e) => {
if (e.touches.length === 1) {
const end = e.changedTouches.item(0).clientX;
setOffset(end - touchStartX);
} else {
setTouchStartX(null);
setOffset(0);
}
}
const handleTouchEnd = (/** TouchEvent **/e) => {
const minOffset = 100; //at least 100px are a swipe
if (touchStartX) {
//the only finger that hit the screen left it
const end = e.changedTouches.item(0).clientX;
if (end > touchStartX + minOffset) {
//a left -> right swipe
handleGoPrevious();
} else if (end < touchStartX - minOffset) {
//a right -> left swipe
handleGoNext();
}
}
setOffset(0);
}
const previousPhotoStyle = {
right: `calc(100% + ${15 - offset}px)`
}
const nextPhotoStyle = {
left: `calc(100% - ${-offset}px)`
}
const photoStyle = {
marginLeft: `${offset}px`
}
return <>
{selectedPhotoIndex >= 0 ?
<div tabIndex={1} ref={elPhotoViewer} id="photoViewer" onClick={handleClick} onKeyUp={handleKeyUp}
onTouchStart={handleTouchStart} onTouchEnd={handleTouchEnd} onTouchMove={handleTouchMove}>
<div className="body-overlay"></div>
<div className="goto-btn goto-btn--previous" onClick={handleGoPrevious}>
{hasPrevious && <ArrowLeft/>}
</div>
<div id="photoSlider">
{hasPrevious && <div className="photo photo--placeholder photo--previous" style={previousPhotoStyle}>
<img src={`${selectedAlbum.photos[selectedPhotoIndex - 1].url}`}
alt={selectedAlbum.photos[selectedPhotoIndex - 1].name}/>
</div>}
<div className="photo photo--invisible">
<img src={`${selectedAlbum.photos[selectedPhotoIndex].url}`}
alt={selectedAlbum.photos[selectedPhotoIndex].name}/>
</div>
<div className="photo photo--placeholder" style={photoStyle}>
<img src={`${selectedAlbum.photos[selectedPhotoIndex].url}`}
alt={selectedAlbum.photos[selectedPhotoIndex].name}/>
</div>
{hasNext && <div className="photo photo--placeholder photo--next" style={nextPhotoStyle}>
<img src={`${selectedAlbum.photos[selectedPhotoIndex + 1].url}`}
alt={selectedAlbum.photos[selectedPhotoIndex + 1].name}/>
</div>}
</div>
<div className="goto-btn goto-btn--next" onClick={handleGoNext}>
{hasNext && <ArrowRight/>}
</div>
</div> : null}
</>
}
@@ -0,0 +1,74 @@
#photoViewer {
z-index: 999;
position: fixed;
left: 0;
top: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.95);
display: flex;
align-items: center;
justify-content: stretch;
#photoSlider {
position: relative;
display: flex;
align-items: center;
overflow: hidden;
flex-shrink: 1;
max-height: 90vh;
.photo {
position: relative;
img {
margin: 0 auto;
max-height: 90vh;
max-width: calc(100vw - 300px);
@media (max-width: 768px) {
max-width: calc(100vw - 150px);
}
}
&.photo--placeholder {
position: absolute;
}
&.photo--invisible {
visibility: hidden;
}
}
}
.goto-btn {
cursor: pointer;
flex: 1;
&:hover {
svg {
transform: scale(1.2);
opacity: 0.5;
}
}
svg {
color: white;
opacity: 0.15;
width: 75px;
height: 75px;
@media (max-width: 768px) {
width: 75px;
height: 75px;
}
}
&.goto-btn--previous {
text-align: right;
}
&.goto-btn--next {
}
}
}
+20
View File
@@ -0,0 +1,20 @@
import {useEffect} from "react";
/**
* Hook that alerts clicks outside of the passed ref
*/
export function useOutsideAlerter(ref, handler) {
useEffect(() => {
function handleClickOutside(event) {
if (ref.current && !ref.current.contains(event.target)) {
handler(event);
}
}
// Bind the event listener
document.addEventListener("mousedown", handleClickOutside);
return () => {
// Unbind the event listener on clean up
document.removeEventListener("mousedown", handleClickOutside);
};
}, [ref, handler]);
}
+32
View File
@@ -0,0 +1,32 @@
body {
margin: 0;
font-size: 16pt;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
#root {
background: #fff;
min-height: 100vh;
font-size: 1rem;
display: flex;
flex-direction: column;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
input {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
}
+45
View File
@@ -0,0 +1,45 @@
import React, {useLayoutEffect} from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import {BrowserRouter, Navigate, Route, Routes, useLocation} from "react-router-dom";
import '@fontsource/roboto/300.css';
import '@fontsource/roboto/400.css';
import '@fontsource/roboto/500.css';
import '@fontsource/roboto/700.css';
import PublicLayout from "./public/layout";
import Portfolio from "./public/pages/portfolio";
import AlbumPage from "./public/pages/album";
import AccessDenied from "./public/pages/access-denied";
import {HomePage} from "./public/pages/home-page";
import {CurrentInstanceProvider} from "./services/current-instance/current-instance-context";
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<BrowserRouter>
<CurrentInstanceProvider>
<ScrollToTop>
<Routes>
<Route index element={<HomePage/>}/>
<Route path="/portfolio" element={<PublicLayout/>}>
<Route index element={<Portfolio/>}/>
<Route path=":albumId" element={<AlbumPage/>}/>
<Route path="401" element={<AccessDenied/>}/>
<Route path="*" element={<Navigate to="/"/>}/>
</Route>
</Routes>
</ScrollToTop>
</CurrentInstanceProvider>
</BrowserRouter>
</React.StrictMode>
);
function ScrollToTop({children}) {
const location = useLocation();
useLayoutEffect(() => {
document.documentElement.scrollTo(0, 0);
}, [location.pathname]);
return children
}
+16
View File
@@ -0,0 +1,16 @@
export function Logo() {
return <svg width="50px" height="40px" fill="currentcolor" viewBox="0 0 49 40">
<g id="surface1">
<path d="M 15.554688 0.195312 C 15.027344 0.40625 14.472656 0.863281 14.191406 1.3125 C 14.019531 1.585938 13.792969 2.351562 13.792969 2.640625 C 13.792969 2.695312 17.792969 2.730469 24.507812 2.730469 L 35.222656 2.730469 L 35.222656 2.542969 C 35.222656 2.210938 35.023438 1.539062 34.824219 1.222656 C 34.554688 0.785156 34.078125 0.40625 33.515625 0.183594 C 33.046875 0 32.96875 0 24.527344 0.0078125 C 16.144531 0.0078125 15.996094 0.0195312 15.554688 0.195312 Z M 15.554688 0.195312 "/>
<path d="M 7.289062 3.707031 C 4.960938 3.75 4.683594 3.769531 4.25 3.933594 C 3.390625 4.269531 3.113281 4.402344 2.664062 4.726562 C 1.441406 5.582031 0.695312 6.65625 0.261719 8.160156 C 0.113281 8.679688 0.09375 9.445312 0.0859375 21.761719 L 0.0703125 34.816406 L 0.261719 35.429688 C 0.605469 36.539062 1.015625 37.210938 1.867188 38.070312 C 2.6875 38.917969 3.226562 39.261719 4.363281 39.683594 L 4.988281 39.910156 L 9.542969 39.945312 C 12.050781 39.957031 14.367188 39.9375 14.6875 39.902344 L 15.277344 39.832031 L 14.601562 39.339844 C 13.371094 38.433594 12.023438 37.085938 11.253906 35.976562 L 10.898438 35.476562 L 6.019531 35.476562 L 5.578125 35.253906 C 5.101562 35.007812 4.773438 34.640625 4.617188 34.164062 C 4.46875 33.730469 4.46875 9.894531 4.617188 9.410156 C 4.738281 8.980469 5.195312 8.433594 5.585938 8.238281 C 5.855469 8.105469 7.46875 8.097656 24.5 8.097656 C 39.996094 8.097656 43.171875 8.117188 43.402344 8.222656 C 43.828125 8.398438 44.210938 8.792969 44.417969 9.269531 L 44.601562 9.691406 L 44.539062 21.902344 C 44.488281 33.496094 44.472656 34.128906 44.324219 34.4375 C 44.125 34.839844 43.605469 35.28125 43.1875 35.394531 C 42.96875 35.449219 39.371094 35.476562 32.820312 35.457031 C 23.503906 35.429688 22.738281 35.421875 22.339844 35.273438 C 22.105469 35.195312 21.65625 35.035156 21.34375 34.929688 C 20.066406 34.507812 18.773438 33.660156 17.664062 32.535156 C 16.996094 31.855469 16.136719 30.757812 16.136719 30.582031 C 16.136719 30.554688 16.066406 30.421875 15.972656 30.300781 C 15.746094 29.964844 15.320312 28.847656 15.085938 27.984375 C 14.921875 27.339844 14.894531 27.042969 14.894531 25.75 C 14.894531 24.628906 14.9375 24.136719 15.042969 23.8125 C 15.121094 23.566406 15.183594 23.300781 15.183594 23.214844 C 15.183594 22.957031 15.789062 21.46875 16.164062 20.820312 C 17.757812 18.011719 20.371094 16.285156 23.570312 15.933594 C 26.972656 15.554688 30.652344 17.296875 32.542969 20.195312 C 33.097656 21.046875 33.660156 22.296875 33.886719 23.167969 C 34.070312 23.882812 34.09375 24.171875 34.09375 25.597656 C 34.09375 27.492188 33.949219 28.34375 33.390625 29.570312 C 33.019531 30.371094 32.28125 31.550781 31.734375 32.175781 L 31.386719 32.570312 L 34.136719 32.554688 L 36.878906 32.527344 L 37.046875 32.21875 C 37.816406 30.765625 38.363281 28.792969 38.527344 26.867188 C 38.804688 23.75 38.058594 20.652344 36.402344 18 C 35.007812 15.757812 33.160156 14.074219 30.753906 12.871094 C 27.753906 11.355469 23.632812 11.03125 20.386719 12.050781 C 17.332031 13 14.601562 15.054688 12.855469 17.695312 C 11.851562 19.226562 11.148438 20.949219 10.707031 22.976562 C 10.472656 24.058594 10.472656 27.351562 10.707031 28.433594 C 11.582031 32.457031 13.492188 35.386719 16.640625 37.527344 C 17.984375 38.441406 19.617188 39.144531 21.507812 39.628906 C 22.253906 39.824219 22.296875 39.824219 33.203125 39.851562 L 44.148438 39.886719 L 44.773438 39.65625 C 46.570312 39.023438 47.847656 37.800781 48.53125 36.054688 C 49.042969 34.753906 49.027344 35.441406 48.964844 21.523438 C 48.929688 13.566406 48.878906 8.625 48.828125 8.40625 C 48.226562 5.933594 46.285156 4.09375 43.855469 3.6875 C 43.414062 3.617188 38.816406 3.601562 26.546875 3.617188 C 17.359375 3.636719 8.691406 3.671875 7.289062 3.707031 Z M 7.289062 3.707031 "/>
<path d="M 6.707031 10.007812 C 6.628906 10.035156 6.59375 10.265625 6.59375 10.703125 L 6.59375 11.355469 L 13.621094 11.355469 L 13.621094 10.703125 C 13.621094 10.210938 13.585938 10.042969 13.492188 10.007812 C 13.335938 9.9375 6.855469 9.9375 6.707031 10.007812 Z M 6.707031 10.007812 "/>
<path d="M 22.902344 17.121094 C 20.65625 17.613281 18.703125 18.890625 17.523438 20.660156 C 17.132812 21.242188 17.109375 21.320312 17.28125 21.257812 C 17.394531 21.222656 18.425781 21.179688 19.582031 21.152344 C 21.246094 21.117188 21.90625 21.136719 22.789062 21.257812 C 24.039062 21.425781 25.339844 21.714844 26.242188 22.042969 C 26.574219 22.164062 26.859375 22.246094 26.878906 22.226562 C 26.988281 22.113281 25.792969 19.929688 24.570312 18.027344 C 23.824219 16.867188 23.902344 16.910156 22.902344 17.121094 Z M 22.902344 17.121094 "/>
<path d="M 24.863281 17.21875 C 26.226562 19.277344 26.929688 20.535156 27.5625 22.015625 C 28.089844 23.25 28.324219 24.03125 28.691406 25.738281 L 28.757812 26.082031 L 29.296875 25.253906 C 30.097656 24.003906 31.667969 20.96875 31.667969 20.660156 C 31.667969 20.449219 30.234375 18.96875 29.609375 18.546875 C 28.292969 17.648438 26.816406 17.09375 25.472656 17.015625 L 24.691406 16.964844 Z M 24.863281 17.21875 "/>
<path d="M 31.96875 21.671875 C 31.855469 22.035156 30.796875 24.136719 30.261719 25.0625 C 29.480469 26.390625 28.714844 27.386719 27.546875 28.582031 C 26.652344 29.507812 26.519531 29.675781 26.667969 29.71875 C 26.945312 29.804688 29.636719 29.78125 30.625 29.683594 C 31.128906 29.628906 31.753906 29.578125 32.011719 29.558594 L 32.492188 29.542969 L 32.644531 29.144531 C 33.082031 28.027344 33.175781 27.472656 33.175781 25.925781 C 33.183594 24.613281 33.160156 24.339844 32.976562 23.679688 C 32.777344 22.929688 32.394531 22 32.160156 21.644531 L 32.03125 21.460938 Z M 31.96875 21.671875 "/>
<path d="M 18.261719 22.007812 C 17.742188 22.042969 17.144531 22.078125 16.945312 22.085938 L 16.585938 22.09375 L 16.363281 22.683594 C 15.484375 25.042969 15.609375 27.570312 16.710938 29.78125 C 16.855469 30.070312 16.960938 30.183594 16.988281 30.105469 C 17.046875 29.902344 17.976562 28.027344 18.304688 27.421875 C 19.214844 25.765625 20.335938 24.277344 21.636719 23 L 22.617188 22.035156 L 22.042969 21.972656 C 21.359375 21.910156 19.570312 21.929688 18.261719 22.007812 Z M 18.261719 22.007812 "/>
<path d="M 20.292969 25.667969 C 20.128906 25.855469 19.171875 27.351562 19.171875 27.410156 C 19.171875 27.4375 19.050781 27.667969 18.902344 27.921875 C 18.757812 28.179688 18.359375 28.933594 18.027344 29.605469 C 17.308594 31.046875 17.289062 30.90625 18.347656 32.007812 C 19.796875 33.511719 21.6875 34.445312 23.546875 34.570312 L 24.40625 34.621094 L 23.972656 33.96875 C 22.566406 31.847656 21.523438 29.78125 20.996094 28.035156 C 20.832031 27.492188 20.5 26.082031 20.457031 25.714844 C 20.429688 25.511719 20.421875 25.511719 20.292969 25.667969 Z M 20.292969 25.667969 "/>
<path d="M 22.46875 29.675781 C 22.574219 29.886719 22.722656 30.203125 22.800781 30.371094 C 23.46875 31.75 25.273438 34.59375 25.488281 34.59375 C 25.878906 34.59375 26.800781 34.394531 27.492188 34.15625 C 29.332031 33.53125 30.9375 32.261719 31.933594 30.632812 L 32.074219 30.414062 L 29.332031 30.414062 C 26.21875 30.414062 25.359375 30.308594 23.511719 29.699219 C 23.292969 29.621094 23.007812 29.535156 22.859375 29.5 C 22.722656 29.453125 22.53125 29.394531 22.445312 29.355469 C 22.296875 29.296875 22.296875 29.3125 22.46875 29.675781 Z M 22.46875 29.675781 "/>
</g>
</svg>;
}
+128
View File
@@ -0,0 +1,128 @@
@import "./variables";
@keyframes sb_slide_negative_left_to_right {
from {
transform: translateX(-$sidebar_width_1920);
@media(max-width: 1920px) {
transform: translateX(-$sidebar_width_1024_1920);
}
@media(max-width: 1024px) {
transform: translateX(-$sidebar_width_768_1024);
}
@media(max-width: 768px) {
transform: translateX(-$sidebar_width_480_768);
}
}
to {
transform: translateX(0);
}
}
@mixin sb_slide_negative_left_to_right() {
transition: 0.6s;
animation: ease-out;
animation-name: sb_slide_negative_left_to_right;
transform: translateX(0);
}
@keyframes sb_slide_right_to_negative_left {
from {
transform: translateX(0);
}
to {
transform: translateX(-$sidebar_width_1920);
@media(max-width: 1920px) {
transform: translateX(-$sidebar_width_1024_1920);
}
@media(max-width: 1024px) {
transform: translateX(-$sidebar_width_768_1024);
}
@media(max-width: 768px) {
transform: translateX(-$sidebar_width_480_768);
}
}
}
@mixin sb_slide_right_to_negative_left() {
transition: 0.6s;
animation: ease-out;
animation-name: sb_slide_right_to_negative_left;
transform: translateX(-1 * $sidebar_width_1920);
@media(max-width: 1920px) {
transform: translateX(-1 * $sidebar_width_1024_1920);
}
@media(max-width: 1024px) {
transform: translateX(-1 * $sidebar_width_768_1024);
}
@media(max-width: 768px) {
transform: translateX(-1 * $sidebar_width_480_768);
}
}
@keyframes sb_slide_left_to_right {
from {
transform: translateX(0);
}
to {
transform: translateX($sidebar_width_1920);
@media(max-width: 1920px) {
transform: translateX($sidebar_width_1024_1920);
}
@media(max-width: 1024px) {
transform: translateX($sidebar_width_768_1024);
}
@media(max-width: 768px) {
transform: translateX($sidebar_width_480_768);
}
}
}
@mixin sb_slide_left_to_right() {
transition: 0.6s;
animation: ease-out;
animation-name: sb_slide_left_to_right;
transform: translateX($sidebar_width_1920);
@media(max-width: 1920px) {
transform: translateX($sidebar_width_1024_1920);
}
@media(max-width: 1024px) {
transform: translateX($sidebar_width_768_1024);
}
@media(max-width: 768px) {
transform: translateX($sidebar_width_480_768);
}
}
@keyframes sb_slide_right_to_left {
from {
transform: translateX($sidebar_width_1920);
@media(max-width: 1920px) {
transform: translateX($sidebar_width_1024_1920);
}
@media(max-width: 1024px) {
transform: translateX($sidebar_width_768_1024);
}
@media(max-width: 768px) {
transform: translateX($sidebar_width_480_768);
}
}
to {
transform: translateX(0);
}
}
@mixin sb_slide_right_to_left() {
transition: 0.6s;
animation: ease-out;
animation-name: sb_slide_right_to_left;
transform: translateX(0);
}
+34
View File
@@ -0,0 +1,34 @@
import {Photo} from "./photo.js";
export class Album {
/**
* @param {Album} [album]
*/
constructor(album) {
this._id = album?._id;
this.albumId = album?.albumId;
this.name = album?.name;
this.description = album?.description;
this.sort = album?.sort;
this.sortOrder = album?.sortOrder;
this.photos = album?.photos?.map(photo => new Photo(photo)) || [];
this.coverPhoto = this.photos.length > 0 ? this.photos[0] : null;
}
/** @type {string} **/
id;
/** @type {string} **/
albumId;
/** @type {string} **/
name;
/** @type {string} **/
description;
/** @type {"manual"|"auto"} **/
sort;
/** @type {number} **/
sortOrder;
/** @type {Photo[]} **/
photos;
/** @type {Photo} **/
coverPhoto;
}
+11
View File
@@ -0,0 +1,11 @@
import {Photo} from "./photo";
export class Instance {
constructor(instance) {
this._id = instance?._id;
this.instanceId = instance?.instanceId;
this.title = instance?.title;
this.subtitle = instance?.subtitle;
this.coverPhoto = instance?.coverPhoto ? new Photo(instance.coverPhoto) : null;
}
}
+29
View File
@@ -0,0 +1,29 @@
export class Photo {
/**
* @param {Photo} photo
*/
constructor(photo) {
this.photoId = photo?.photoId;
this.albumId = photo?.albumId;
this.name = photo?.name;
this.fileName = photo?.fileName;
this.mimeType = photo?.mimeType;
this.s3Path = photo?.s3Path;
this.description = photo?.description;
this.sortOrder = photo?.sortOrder;
this.url = `${window._env_.REACT_APP_API_URL}/api/s3/${this.s3Path}`;
}
/** @type {string} **/
photoId;
/** @type {string} **/
albumId;
/** @type {string} **/
name;
/** @type {string} **/
s3Path;
/** @type {string} **/
description;
/** @type {number} **/
sortOrder;
}
@@ -0,0 +1,5 @@
import {createContext} from "react";
export const PageTransitionContext = createContext({
mounted: false
})
+57
View File
@@ -0,0 +1,57 @@
import * as React from "react";
import "./public.scss"
import {useLayoutEffect, useState} from "react";
import {NavLink, Outlet, useLocation} from "react-router-dom";
import {KeyboardArrowRightRounded, Menu} from "@mui/icons-material";
import {PageTransitionContext} from "./context/pageTransition";
import {PhotoViewer} from "../components/photo-viewer/photo-viewer";
import Loader from "../components/Loader";
import {useCurrentInstance} from "../services/current-instance/use-current-instance";
export default function PublicLayout() {
const {isLoading, apiError, albums} = useCurrentInstance();
const [mounted, setMounted] = useState(false);
const pageTransition = {
mounted, setMounted
}
// const fetchedRef = useRef(false);
const [sidebarIsOpen, setSidebarIsOpen] = useState(false);
const location = useLocation();
useLayoutEffect(() => {
setMounted(false);
setTimeout(() => {
setMounted(true);
}, 10);
}, [location.pathname]);
return <PageTransitionContext.Provider value={pageTransition}>
<PhotoViewer />
<div id="publicPage">
<main id="page">
<Loader loading={isLoading} />
{!isLoading && <>
<div id="sidebarButton" onClick={() => setSidebarIsOpen(!sidebarIsOpen)}>
<Menu />
</div>
<div id="sidebar" className={`${(sidebarIsOpen ? "sidebar-open" : "sidebar-closed")}`}>
<div className="sidebar">
<NavLink onClick={() => setSidebarIsOpen(false)} to="/" className="home">Home</NavLink>
{albums.map(a => <NavLink onClick={() => setSidebarIsOpen(false)} key={a.albumId} to={a.albumId}><KeyboardArrowRightRounded /> {a.name}</NavLink>)}
</div>
</div>
<div id="sidebar-mobile" className={`sidebar ${(sidebarIsOpen ? "sidebar-open" : "sidebar-closed")}`}>
<NavLink onClick={() => setSidebarIsOpen(false)} to="/" className="home">Home</NavLink>
{albums.map(a => <NavLink onClick={() => setSidebarIsOpen(false)} key={a.albumId} to={a.albumId}><KeyboardArrowRightRounded /> {a.name}</NavLink>)}
</div>
<div id={"page-content"} className={`${(sidebarIsOpen ? "sidebar-open" : "sidebar-closed")} ${(pageTransition.mounted ? "mounted" : "unmounted")}`}>
{apiError && <div className="error-box">{apiError}</div>}
{!apiError && <Outlet />}
</div>
</>}
</main>
</div>
</PageTransitionContext.Provider>
}
+7
View File
@@ -0,0 +1,7 @@
function AccessDenied() {
return (
<h1>Access denied!</h1>
)
}
export default AccessDenied;
+53
View File
@@ -0,0 +1,53 @@
import * as React from "react";
import {useEffect} from "react";
import {useParams} from "react-router-dom";
import {ZoomOutMapSharp} from "@mui/icons-material";
import {useCurrentInstance} from "../../services/current-instance/use-current-instance";
export default function AlbumPage() {
const {
albums,
selectedAlbum,
setSelectedAlbum,
setSelectedPhotoIndex
} = useCurrentInstance();
const {albumId} = useParams();
useEffect(() => {
const ab = albums.filter(a => a.albumId === albumId)[0];
if (selectedAlbum !== ab) {
setSelectedAlbum(ab);
}
}, [albums, selectedAlbum, albumId, setSelectedAlbum]);
return selectedAlbum && <div id="page-album" className="page-outlet">
<div className="page-title">
<div className="title">
{selectedAlbum.name}
</div>
{selectedAlbum.description &&
<div className="extra-info"
dangerouslySetInnerHTML={{__html: selectedAlbum.description.replace(/\n/g, "<br />")}}>
</div>}
</div>
{selectedAlbum.photos && <div id="photo-list" className="photo-grid-layout">
{selectedAlbum.photos.map((photo, idx) =>
<div className="photo grid-cell" key={photo.photoId}
onClick={() => setSelectedPhotoIndex(idx)}>
<div className="thumbnail photo-thumbnail">
<img src={`${photo.url}?width=800&height=800&fit=cover`}
alt={photo.name}/>
<div className="zoom-button">
<ZoomOutMapSharp/>
</div>
</div>
<div className="details">
<div className="name">
{photo.name}
</div>
</div>
</div>
)}
</div>}
</div>
}
+23
View File
@@ -0,0 +1,23 @@
import {useCurrentInstance} from "../../services/current-instance/use-current-instance";
import "./home-page.scss";
import {NavLink} from "react-router-dom";
export function HomePage() {
const {instance} = useCurrentInstance();
return instance && <div id="home">
<div className="cover-photo">
<img alt={instance.coverPhoto?.name} src={instance.coverPhoto?.url}/>
</div>
<div className="instance-details">
<div className="box">
<h1>{instance.title}</h1>
<h2>{instance.subtitle}</h2>
<NavLink to="/portfolio" className="goto-website-link">
GO TO WEBSITE
</NavLink>
</div>
</div>
</div>;
}
+99
View File
@@ -0,0 +1,99 @@
#home {
height: 100vh;
width: 100vw;
display: grid;
grid-template-columns: 1fr 1fr;
@media(max-width: 1200px) {
grid-template-columns: 1fr;
grid-template-rows: 1fr 1fr;
}
.cover-photo {
overflow: hidden;
display: flex;
justify-content: center;
align-items: center;
img {
width: 100%;
height: 100%;
object-position: 50% 50%;
object-fit: cover;
}
}
.instance-details {
display: flex;
justify-content: center;
flex-direction: column;
.box {
position: static !important;
transform: none;
margin-left: auto;
margin-right: auto;
padding: 60px;
max-width: 620px;
text-align: center;
h1 {
text-align: center;
display: inline-block;
font-size: 37px;
line-height: 1em;
margin: 0;
margin-bottom: 22px;
//font-family: proxima-nova;
font-weight: 700;
font-style: normal;
text-transform: uppercase;
letter-spacing: .1em;
color: #333;
@media(max-width: 600px) {
font-size: 24px;
}
}
h2 {
//font-family: minion-pro;
font-size: 19px;
font-weight: 400;
font-style: normal;
text-transform: uppercase;
letter-spacing: .28em;
line-height: 1.7em;
color: #333;
margin: 0;
margin-bottom: 36px;
}
.goto-website-link {
text-rendering: optimizeLegibility;
white-space: nowrap;
line-height: 1em;
//font-family: proxima-nova;
font-weight: 500;
font-style: normal;
font-size: 14px;
text-transform: uppercase;
letter-spacing: 0;
text-align: center;
padding: 1em calc(1.44em - 0em) 1em 1.44em;
display: inline-block;
text-decoration: none;
background-color: transparent;
color: #333;
border: 2px solid #333;
transition: background-color 170ms ease-in-out, color 170ms ease-in-out;
//transition: color 170ms ease-in-out, border-color 170ms ease-in-out;
&:hover {
background-color: black;
color: white;
}
}
}
}
}
+33
View File
@@ -0,0 +1,33 @@
import {Link} from "react-router-dom";
import * as React from "react";
import {ImageSearch} from "@mui/icons-material";
import {useCurrentInstance} from "../../services/current-instance/use-current-instance";
function Portfolio() {
const {instance, albums} = useCurrentInstance();
return instance && <div id="page-portfolio" className="page-outlet">
<div className="page-title">
<div className="title">
{instance.title}
</div>
</div>
{albums?.length > 0 && <div id="album-list" className="photo-grid-layout">
{albums.map((album) =>
<div className="album grid-cell" key={album.albumId}>
<Link to={album.albumId} className={`thumbnail ${(album.coverPhoto ? "" : "thumbnail--no-img")}`}>
{album.coverPhoto && <img src={`${album.coverPhoto.url}?width=800&height=800&fit=cover`} alt={album.coverPhoto.name} />}
{!album.coverPhoto && <ImageSearch />}
</Link>
<div className="details">
<div className="name">
{album.name}
</div>
</div>
</div>
)}
</div>}
</div>;
}
export default Portfolio;
+308
View File
@@ -0,0 +1,308 @@
@import "../variables.scss";
@import "../mixins.scss";
html:has(#photoViewer) {
overflow: hidden;
}
#publicPage {
margin: 0 auto;
width: 100%;
flex: 1;
display: flex;
flex-direction: row;
padding: 15px;
#page {
width: 100%;
overflow: hidden;
position: relative;
flex: 1;
@media(max-width: $sidebar_mobile_breakpoint) {
flex-direction: column;
}
#sidebarButton {
cursor: pointer;
z-index: 999;
position: relative;
width: 30px;
height: 30px;
> svg {
width: 30px;
height: 30px;
}
}
#sidebar {
padding-left: 15px;
padding-top: 15px;
height: 100vh;
overflow: hidden;
display: flex;
flex-direction: column;
position: fixed;
@media(max-width: $sidebar_mobile_breakpoint) {
display: none;
}
&.sidebar-open {
@include sb_slide_negative_left_to_right();
}
&.sidebar-closed {
@include sb_slide_right_to_negative_left();
}
}
#sidebar-mobile {
padding-left: 50px;
background: white;
width: 100%;
overflow-y: auto;
display: none;
flex-direction: column;
height: $sidebar_mobile_height;
border-bottom: 1px solid #000;
@media(max-width: $sidebar_mobile_breakpoint) {
display: flex;
}
@keyframes slideDown {
from {
margin-top: calc(-1 * $sidebar_mobile_height - 50px);
}
to {
margin-top: 0;
}
}
&.sidebar-open {
margin-top: 0;
transition: 0.5s;
animation: ease-in;
animation-name: slideDown;
}
@keyframes slideUp {
from {
margin-top: 0;
}
to {
margin-top: calc(-1 * $sidebar_mobile_height - 50px);
}
}
&.sidebar-closed {
margin-top: calc(-1 * $sidebar_mobile_height - 50px);
transition: 0.5s;
animation: ease-in;
animation-name: slideUp
}
}
.sidebar {
> a {
width: 100%;
font-weight: 400;
line-height: 1.4em;
color: #000;
text-decoration: none;
display: flex;
align-items: flex-start;
margin-bottom: 5px;
svg {
height: 19px;
width: 24px;
}
&:hover,
&.active {
font-weight: 600;
}
&:visited,
&:hover {
color: #000;
}
}
}
#page-content {
padding-top: 20px;
display: flex;
width: 100%;
.page-outlet {
transform: translateX(100vw);
@media (max-width: $sidebar_mobile_breakpoint) {
transform: translateX(0);
margin-top: 50px;
}
}
&.mounted {
.page-outlet {
@include sb_slide_right_to_left();
}
}
&.sidebar-closed {
@include sb_slide_right_to_left();
@media (max-width: $sidebar_mobile_breakpoint) {
animation: none;
transform: translateX(0);
}
}
&.sidebar-open {
@include sb_slide_left_to_right();
@media (max-width: $sidebar_mobile_breakpoint) {
animation: none;
transform: translateX(0);
}
}
.page-title {
margin-bottom: 15px;
.title {
font-size: 2.4rem;
margin-bottom: 15px;
}
.extra-info {
font-size: 1.2rem;
color: #333;
font-style: italic;
}
}
.photo-grid-layout {
width: 100%;
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-auto-rows: 1fr;
row-gap: calc(4em + 30px);
column-gap: 30px;
margin-bottom: 4em;
//@media (max-width: 1920px) {
// grid-template-columns: repeat(4, 1fr);
//}
//@media (max-width: 1280px) {
// grid-template-columns: repeat(4, 1fr);
//}
@media (max-width: 768px) {
grid-template-columns: repeat(2, 1fr);
}
.grid-cell {
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
position: relative;
cursor: pointer;
&:hover {
//transform: scale(1.05);
}
.thumbnail {
width: 100%;
position: relative;
img {
width: 100%;
}
.zoom-button {
position: absolute;
left: 0;
top: 0;
height: 100%;
width: 100%;
display: flex;
align-items: center;
background-color:rgba(0,0,0, 0);
transition: background-color 0.6s;
&:hover {
background-color:rgba(0,0,0, 0.35);
svg {
transform: scale(1.4);
color: rgba(255, 255, 255, 0.7);
}
}
svg {
margin: 0 auto;
width: 20%;
height: 20%;
color: rgba(255, 255, 255, 0.2);
transition: transform 0.6s, color 0.6s;
}
}
&.thumbnail--no-img {
width: 100%;
text-align: center;
height: 100%;
padding-top: calc(50% - 75px);
> svg {
width: 75px;
height: 75px;
color: $accentColor;
}
}
}
.details {
position: absolute;
bottom: calc(-4em + 5px);
display: flex;
flex-direction: column;
height: 4em;
width: 80%;
margin: 0 auto;
text-align: center;
.name {
margin-top: 15px;
}
}
}
}
}
.error-box {
padding: 0 30px;
height: 60px;
line-height: 60px;
background: lighten(red, 46);
border: 2px solid crimson;
border-radius: $borderRadius;
color: crimson;
font-size: 1.2rem;
margin: 15px auto 0;
}
#page-loader {
position: absolute;
padding-left: 50%;
padding-top: 120px;
> span > span {
background-color: $accentColor !important;
}
}
}
}
+34
View File
@@ -0,0 +1,34 @@
import {Album} from "../../models/album";
import {Instance} from "../../models/instance";
const apiUrl = window._env_.REACT_APP_API_URL;
export default function usePublicApi() {
return {
fetchAlbums
};
/**
* @return {Promise<{instance: Instance, albums: Album[]}>}
*/
async function fetchAlbums() {
const response = await handleResponse(await fetch(`${apiUrl}/api/albums`));
return {
instance: new Instance(response.data.instance),
albums: response.data.albums.map(album => new Album(album))
};
}
async function handleResponse(response) {
const text = await response.text()
const data = text && JSON.parse(text);
if (!response.ok) {
const error = (data && data.message) || response.statusText;
return Promise.reject(error);
}
return data;
}
}
@@ -0,0 +1,57 @@
import React, {useEffect, useState} from "react";
import usePublicApi from "../../public/services/public-api";
export const CurrentInstanceContext = React.createContext(
undefined
);
export const CurrentInstanceProvider = ({children}) => {
const {fetchAlbums} = usePublicApi();
const [initialized, setInitialized] = useState(false);
const [instance, setInstance] = useState(null);
const [albums, setAlbums] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const [apiError, setApiError] = useState(null);
const [selectedAlbum, setSelectedAlbum] = useState(null);
const [selectedPhotoIndex, setSelectedPhotoIndex] = useState(-1);
useEffect(() => {
void (async () => {
if (fetchAlbums && !initialized) {
setInitialized(true);
try {
setApiError(null);
const {instance, albums} = await fetchAlbums();
document.title = `${instance.title} | ${instance.subtitle}`;
setAlbums(albums);
setInstance(instance);
setIsLoading(false);
} catch (err) {
console.error("Could not fetch albums", err);
setApiError("An error occurred trying to fetch your albums, please contact the system administrator");
setIsLoading(false);
setAlbums([]);
setInstance(null);
}
}
})();
}, [fetchAlbums, initialized]);
const value = {
instance,
setInstance,
albums,
setAlbums,
isLoading,
setIsLoading,
apiError,
setApiError,
selectedAlbum,
setSelectedAlbum,
selectedPhotoIndex,
setSelectedPhotoIndex
};
return <CurrentInstanceContext.Provider value={value}>{children}</CurrentInstanceContext.Provider>;
};
@@ -0,0 +1,17 @@
import {CurrentInstanceContext} from "./current-instance-context";
import {useContext} from "react";
/**
* @return {InstancesContext}
*/
export const useCurrentInstance = () => {
const context = useContext(CurrentInstanceContext);
if (!context) {
throw new Error(
'Current instance context is undefined, please verify you are calling useCurrentInstance() as child of a <CurrentInstanceProvider> component.',
);
}
return context;
};
+23
View File
@@ -0,0 +1,23 @@
export const createAnimation = (loaderName, frames, suffix) => {
const animationName = `react-spinners-${loaderName}-${suffix}`;
if (typeof window == "undefined" || !window.document) {
return animationName;
}
const styleEl = document.createElement("style");
document.head.appendChild(styleEl);
const styleSheet = styleEl.sheet;
const keyFrames = `
@keyframes ${animationName} {
${frames}
}
`;
if (styleSheet) {
styleSheet.insertRule(keyFrames, 0);
}
return animationName;
};
+16
View File
@@ -0,0 +1,16 @@
$headerSize: 60px;
$pageMargin: 15px;
$headerOffset: 25px;
$borderColor: #999;
$borderRadius: 30px;
$backgroundColor: #fff;
$accentColor: #de5200;
$sidebar_mobile_breakpoint: 768px;
$sidebar_mobile_height: 30vh;
$sidebar_width_1920: 15vw;
$sidebar_width_1024_1920: 25vw;
$sidebar_width_768_1024: 35vw;
$sidebar_width_480_768: 50vw;