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
+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;