Initial commit
This commit is contained in:
Executable
+1
@@ -0,0 +1 @@
|
||||
REACT_APP_API_URL=http://localhost:9001
|
||||
Executable
+27
@@ -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
|
||||
@@ -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;\""]
|
||||
Executable
+13
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
Generated
+17873
File diff suppressed because it is too large
Load Diff
@@ -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 |
@@ -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 |
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
||||
Executable
+1
@@ -0,0 +1 @@
|
||||
docker build . -t photos-client
|
||||
Executable
+9
@@ -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
|
||||
Executable
+32
@@ -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
|
||||
@@ -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;
|
||||
@@ -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 {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
@@ -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>
|
||||
}
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
function AccessDenied() {
|
||||
return (
|
||||
<h1>Access denied!</h1>
|
||||
)
|
||||
}
|
||||
|
||||
export default AccessDenied;
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user