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
+242
View File
@@ -0,0 +1,242 @@
@import "variables";
#admin-page {
margin: 0 auto;
width: 100%;
max-width: 1280px;
flex: 1;
display: flex;
flex-direction: column;
padding: 0 $pageMargin $pageMargin;
#page {
position: relative;
background: #f6f6f6;
flex: 1;
border: 2px solid $accentColor;
border-radius: $borderRadius;
padding: $borderRadius;
}
#page-title {
font-size: 1.8rem;
line-height: 2em;
margin-bottom: 25px;
display: flex;
align-items: center;
justify-content: space-between;
#left-part {
display: flex;
justify-content: flex-start;
button {
padding: 0;
font-size: 2rem;
height: 1.8em;
svg#navigate-back {
width: 1.8em;
height: 1.8em;
}
}
.input-element {
margin-bottom: 0;
margin-left: 15px;
}
.photos-count {
font-size: 0.6em;
line-height: 2em;
color: #333;
font-style: italic;
margin-left: 15px;
}
}
.MuiInput-input {
font-size: 2rem;
}
}
#page-loader {
position: absolute;
padding-left: 50%;
padding-top: 120px;
> span > span {
background-color: $accentColor !important;
}
}
.card-layout {
padding: 15px;
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: space-between;
border: 2px solid $borderColor;
border-radius: $borderRadius;
position: relative;
.cover-photo {
position: relative;
height: 350px;
flex: 0 0 350px;
margin-bottom: 3px;
cursor: pointer;
width: 100%;
max-width: 100%;
border-radius: $borderRadius;
box-shadow: #333 5px 5px 5px;
overflow: hidden;
img {
width: 100%;
transform: scale(1);
}
&:hover {
img,
svg {
transform: scale(1.05);
}
}
.file-name {
position: absolute;
bottom: 0;
background: white;
width: 100%;
text-align: center;
font-size: 0.8em;
font-style: italic;
line-height: 1.2em;
padding: 0 20px;
}
&.cover-photo--no-img {
//padding-left: calc(350px / 2 - 37.5px);
display: flex;
align-items: center;
flex-direction: column;
justify-content: center;
#page-loader {
padding: 9px;
}
svg.no-photos {
width: 75px;
height: 75px;
color: $accentColor;
}
}
}
.card-details {
flex: 1 1 auto;
margin-left: 25px;
margin-top: 25px;
@media(max-width: 968px) {
margin-left: 0;
width: 100%;
max-width: 450px;
}
.input-element {
margin-bottom: 25px;
.input-error {
visibility: hidden;
position: absolute;
bottom: -1.4em;
left: 25px;
font-size: 11pt;
color: red;
font-style: italic;
}
}
}
.input-buttons {
display: flex;
justify-content: space-between;
@media(max-width: 968px) {
flex-direction: column;
}
.left-buttons {
display: flex;
button {
margin-right: 15px;
}
@media(max-width: 968px) {
justify-content: space-between;
margin-bottom: 15px;
button {
margin-right: 0;
}
}
}
.right-buttons {
display: flex;
button {
margin-left: 15px;
}
@media(max-width: 968px) {
justify-content: space-between;
margin-bottom: 15px;
button {
margin-left: 0;
}
}
}
}
}
.upload-error {
width: 100%;
padding: 25px;
bottom: 0;
text-align: center;
position: absolute;
font-size: 11pt;
color: red;
font-style: italic;
}
.upload-success {
width: 100%;
padding: 25px;
bottom: 0;
text-align: center;
position: absolute;
font-size: 11pt;
color: green;
font-style: italic;
}
#photosUpload {
display: none;
}
.error-box {
padding: 15px;
background: lighten(red, 46);
border: 2px solid crimson;
border-radius: $borderRadius;
color: crimson;
font-size: 1rem;
margin-bottom: 15px;
}
}
+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;
+44
View File
@@ -0,0 +1,44 @@
import {useState} from "react";
import "./drag-and-drop.scss";
import Loader from "../Loader";
import * as React from "react";
function DragAndDrop({className, onDrop, children, isLoading, ...otherAtributes}) {
const [dragging, setDragging] = useState(false);
const handleDragEnter = (e) => {
e.preventDefault();
e.stopPropagation();
setDragging(true);
}
const handleDragLeave = (e) => {
e.preventDefault();
e.stopPropagation();
setDragging(false);
}
const handleDragStart = (e) => {
e.dataTransfer.clearData();
}
const handleDrop = (e) => {
e.preventDefault();
e.stopPropagation();
setDragging(false);
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
onDrop(e.dataTransfer.files)
}
}
return (
<div className={`draggable ${dragging ? "draggable--dragging" : ""} ${className || ""}`}
onDragEnter={handleDragEnter} onDragStart={handleDragStart} {...otherAtributes}>
{dragging && <div className="draggable-overlay" onDragLeave={handleDragLeave} onDrop={handleDrop} /> }
<div className="draggable-content">
{children}
</div>
<Loader loading={isLoading}/>
</div>
);
}
export default DragAndDrop;
@@ -0,0 +1,21 @@
.draggable {
position: relative;
&.draggable--dragging {
.draggable-content {
transition: 0.3s;
scale: 1.2
}
}
.draggable-overlay {
position: absolute;
background: transparent;
left: 0;
top: 0;
height: 100%;
width: 100%;
border-radius: 25px;
z-index: 9999;
}
}
+49
View File
@@ -0,0 +1,49 @@
import {useOutsideAlerter} from "../hooks/outside-click-alerter";
import * as React from "react";
import {useNavigate} from "react-router-dom";
import {useRef, useState} from "react";
import {Edit, Person} from '@mui/icons-material';
import {Logo} from "./logo";
import {Button, IconButton} from "@mui/material";
import "./header.scss";
import {useAuth} from "../services/authentication/use-auth";
import {useInstances} from "../services/instances/use-instances";
export default function Header() {
const {selectedInstance} = useInstances();
const navigate = useNavigate();
const {userData, userManager} = useAuth();
const [showUserDropout, setShowUserDropout] = useState(false);
const wrapperRef = useRef(null);
useOutsideAlerter(wrapperRef, () => {
setShowUserDropout(false);
});
const handleSignOut = async () => {
await userManager.signoutRedirect();
navigate("/");
}
const handleUserIconClicked = () => {
setShowUserDropout(!showUserDropout);
}
return <header id="admin-header" ref={wrapperRef}>
<div id="logo" onClick={() => navigate("/")}><Logo/></div>
{selectedInstance && <Button variant="contained" onClick={() => navigate("/")} color="warning">
<Edit /> {selectedInstance.title}
</Button>}
{userData &&
<div id="user-icon">
<IconButton aria-label="person" onClick={handleUserIconClicked}>
<Person/>
</IconButton>
{showUserDropout && userData && <div id="user-icon-dropout">
<div>{userData.profile?.email}</div>
{userData ? <div><Button variant="contained" onClick={handleSignOut}>Sign out</Button></div> : null}
</div>}
</div>}
</header>;
}
+69
View File
@@ -0,0 +1,69 @@
@import "../variables";
header#admin-header {
margin: 0 auto;
width: 100%;
max-width: 1280px;
flex: 0 0 $headerSize;
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
#logo {
flex: 0 0 calc($headerSize - 15px);
height: calc($headerSize - 20px);
margin: 10px calc($pageMargin + $headerOffset);
cursor: pointer;
svg {
width: 100%;
height: 100%;
color: $accentColor;
}
}
#user-icon {
flex: 0 0 calc($headerSize - 20px);
height: calc($headerSize - 20px);
margin: 10px calc($pageMargin + $headerOffset);
position: relative;
> button {
width: 100%;
height: 100%;
border: 3px solid $accentColor;
border-radius: 50%;
padding: 5px;
svg {
width: 100%;
height: 100%;
color: $accentColor;
}
}
}
#user-icon-dropout {
position: absolute;
background: white;
bottom: -130px;
right: -$headerOffset;
display: flex;
flex-direction: column;
justify-content: space-between;
height: 120px;
border: 2px solid $borderColor;
border-radius: $borderRadius;
padding: 15px 50px 30px;
line-height: 2.2em;
z-index: 999;
color: #000;
button {
bottom: 0;
width: 100%;
}
}
}
+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>;
}
@@ -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;
}
+55
View File
@@ -0,0 +1,55 @@
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 Keycloak from "./services/authentication/keycloak";
import PortfolioManager from "./pages/portfolio-manager";
import AlbumManager from "./pages/album-manager";
import {InstanceSelectorPage} from "./pages/instance-selector-page";
import {NoAccessPage} from "./pages/no-access";
import {InstancesProvider} from "./services/instances/instances-context";
import {AdminLayout} from "./layout";
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<BrowserRouter>
<ScrollToTop>
<Keycloak>
<InstancesProvider>
<AdminLayout>
<Routes>
<Route index path="/" element={<InstanceSelectorPage/>}/>
<Route path="/no-access" element={<NoAccessPage/>}/>
<Route path="/:instanceId">
{/*<Route index element={<ManageInstancePage/>}/>*/}
<Route index path="" element={<Navigate to="albums" />} />
<Route path="albums" element={<PortfolioManager/>} />
<Route path="albums/:albumId" element={<AlbumManager/>}/>
<Route path="signin-keycloak" element={<Navigate to="/"/>}/>
<Route path="signin-keycloak" element={<Navigate to="/"/>}/>
</Route>
<Route path="*" element={<Navigate to={"/no-access"}/>}/>
</Routes>
</AdminLayout>
</InstancesProvider>
</Keycloak>
</ScrollToTop>
</BrowserRouter>
</React.StrictMode>
);
function ScrollToTop({children}) {
const location = useLocation();
useLayoutEffect(() => {
document.documentElement.scrollTo(0, 0);
}, [location.pathname]);
return children
}
+68
View File
@@ -0,0 +1,68 @@
import * as React from "react";
import Header from "./components/header";
import {useNavigate} from "react-router-dom";
import {useEffect} from "react";
import "./admin.scss";
import Loader from "./components/Loader";
import {createTheme} from '@mui/material/styles';
import {ThemeProvider} from "@mui/material";
import {useAuth} from "./services/authentication/use-auth";
import {useInstances} from "./services/instances/use-instances";
const theme = createTheme({
palette: {
primary: {
main: '#de5200'
}
},
});
export function AdminLayout({children}) {
const navigate = useNavigate();
const {isLoading, userData, userManager, signIn} = useAuth();
const {isLoading: instancesIsLoading} = useInstances();
useEffect(() => {
async function handleLogin() {
try {
await userManager.signinSilentCallback();
} catch (err) {
}
}
async function silentLogin() {
try {
await userManager.signinSilent();
} catch (err) {
await signIn();
}
}
if (!userData && !isLoading) {
if (window.location.href.indexOf("/signin-keycloak") >= 0) {
handleLogin();
} else if (window.location.href.indexOf("/signin-keycloak") >= 0) {
navigate("/");
} else {
silentLogin();
}
}
}, [userData, isLoading, userManager, navigate, signIn]);
return <ThemeProvider theme={theme}>
<Header/>
<div id="admin-page">
<main id="page">
{userData ? <>
{(isLoading || instancesIsLoading) && <Loader/>}
{!isLoading && !instancesIsLoading && children}
</>
: <Loader/>
}
</main>
</div>
</ThemeProvider>
}
+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;
}
+12
View File
@@ -0,0 +1,12 @@
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;
this.urls = instance?.urls?.map(u => u) || [];
}
}
+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;
}
+302
View File
@@ -0,0 +1,302 @@
import * as React from "react";
import {useLocation, useNavigate} from "react-router-dom";
import {AddAPhoto, ArrowCircleLeftOutlined, MoveDown, MoveUp} from "@mui/icons-material";
import {useRef, useState} from "react";
import useAlbumsApi from "../services/albums-api";
import {Button, IconButton, Input, TextField} from "@mui/material";
import {Photo} from "../models/photo";
import DragAndDrop from "../components/drag-and-drop/drag-and-drop";
import {Album} from "../models/album";
import Loader from "../components/Loader";
import "./album-manager.scss";
import {useInstances} from "../services/instances/use-instances";
function AlbumManager() {
const {state} = useLocation();
const {album} = state; // Read values passed on state
const {selectedInstance} = useInstances();
const navigate = useNavigate();
const {
postAlbum,
patchAlbum,
patchPhoto,
uploadPhotos,
deletePhoto,
movePhotoDown,
movePhotoUp
} = useAlbumsApi();
const [name, setName] = useState(album.name || '');
const [description, setDescription] = useState(album.description || '');
const [sort] = useState(album.sort || "auto" );
const [photosToUpdate, setPhotosToUpdate] = useState(album.photos.map(x => new Photo(x)));
const [uploadApiError, setUploadApiError] = useState(null);
const [apiError, setApiError] = useState(null);
const [uploadSuccess, setUploadSuccess] = useState(null);
const [isUploading, setIsUploading] = useState(false);
const fileInputRef = useRef();
const handleSaveEdit = async (e) => {
try {
if (hasChanges()) {
setApiError(null);
let albumToUpdate = new Album({
...album,
name,
description
});
let updatedAlbum;
if (album.albumId) {
updatedAlbum = await patchAlbum(selectedInstance, albumToUpdate);
} else {
updatedAlbum = await postAlbum(selectedInstance, albumToUpdate);
}
navigate(`/${selectedInstance.instanceId}/albums/${updatedAlbum.albumId}`, {state: {album: updatedAlbum}});
}
} catch (err) {
if (typeof err === "string" && err.toLowerCase().indexOf("duplicate") >= 0) {
setApiError("Name already in use");
} else {
console.error(err);
setApiError("An error occurred trying to save the album, contact the system administrator");
}
}
}
const handleSavePhotoEdit = async (idx) => {
try {
setApiError(null);
if (photoHasChanges(idx)) {
const updatedAlbum = await patchPhoto(
selectedInstance,
album.albumId,
photosToUpdate[idx]
);
navigate(`/${selectedInstance.instanceId}/albums/${updatedAlbum.albumId}`, {state: {album: updatedAlbum}});
}
} catch (err) {
if (typeof err === "string" && err.toLowerCase().indexOf("duplicate") >= 0) {
setApiError("Name already in use");
} else {
console.error(err);
setApiError("An error occurred trying to save the photo, contact the system administrator");
}
}
}
const handleMovePhotoDown = async (idx) => {
try {
setApiError(null);
const updatedAlbum = await movePhotoDown(
selectedInstance,
album.albumId,
photosToUpdate[idx].photoId
);
const orgPhoto = photosToUpdate[idx];
photosToUpdate[idx] = photosToUpdate[idx + 1];
photosToUpdate[idx + 1] = orgPhoto;
setPhotosToUpdate([...photosToUpdate]);
navigate(`/${selectedInstance.instanceId}/albums/${updatedAlbum.albumId}`, {state: {album: updatedAlbum}});
} catch (err) {
console.error(err);
setApiError("An error occurred trying to save the photo, contact the system administrator");
}
}
const handleMovePhotoUp = async (idx) => {
try {
setApiError(null);
const updatedAlbum = await movePhotoUp(
selectedInstance,
album.albumId,
photosToUpdate[idx].photoId
);
const orgPhoto = photosToUpdate[idx];
photosToUpdate[idx] = photosToUpdate[idx - 1];
photosToUpdate[idx - 1] = orgPhoto;
setPhotosToUpdate([...photosToUpdate]);
navigate(`/${selectedInstance.instanceId}/albums/${updatedAlbum.albumId}`, {state: {album: updatedAlbum}});
} catch (err) {
console.error(err);
setApiError("An error occurred trying to save the photo, contact the system administrator");
}
}
const handleDeletePhoto = async (e, photoId) => {
try {
setApiError(null);
const updatedAlbum = await deletePhoto(selectedInstance, album.albumId, photoId);
setPhotosToUpdate(updatedAlbum.photos);
navigate(`/${selectedInstance.instanceId}/albums/${updatedAlbum.albumId}`, {state: {album: updatedAlbum}});
} catch (err) {
setApiError("An error occurred trying to save the photo, contact the system administrator");
}
}
const handlePhotoNameChange = (photoIndex, e) => {
photosToUpdate[photoIndex].name = e.target.value;
setPhotosToUpdate([...photosToUpdate]);
}
const handlePhotoDescriptionChange = (photoIndex, e) => {
photosToUpdate[photoIndex].description = e.target.value;
setPhotosToUpdate([...photosToUpdate]);
}
const hasChanges = () => album.sort !== sort || album.name !== name || album.description !== description;
const photoHasChanges = (idx) => {
return (photosToUpdate[idx].name || "") !== (album.photos[idx].name || "") ||
(photosToUpdate[idx].description || "") !== (album.photos[idx].description || "");
}
const handleNavigateToOverview = (e) => {
navigate(`/${selectedInstance.instanceId}`);
}
const handleDrop = async (files) => {
await handleFiles(files)
}
const handleFiles = async (files) => {
try {
setUploadApiError(null);
setUploadSuccess(null);
setIsUploading(true);
const arrFiles = [...files];
const formData = new FormData();
for (let file of arrFiles) {
formData.append('photos', file);
}
const updatedAlbum = await uploadPhotos(selectedInstance, album.albumId, formData);
setPhotosToUpdate(updatedAlbum.photos);
setUploadSuccess(true);
navigate(`/${selectedInstance.instanceId}/albums/${updatedAlbum.albumId}`, {state: {album: updatedAlbum}});
} catch (err) {
if (typeof err === "string" && err.toLowerCase().indexOf("already exist") >= 0) {
setUploadApiError(err);
} else if (typeof err === "string" && err.toLowerCase().indexOf("mimetype") >= 0) {
setUploadApiError(err);
} else {
console.error(err);
setUploadApiError("An error occurred trying to upload your photo's, contact the system administrator");
}
} finally {
setIsUploading(false);
}
}
return selectedInstance && <div id="admin-page-manage-albums">
<div id="page-title">
<div id="left-part">
<IconButton onClick={handleNavigateToOverview} color="primary">
<ArrowCircleLeftOutlined id="navigate-back"/>
</IconButton>
<div id="edit-title" className="input-element">
<Input onKeyUp={(e) => e.key === "Enter" && handleSaveEdit(e)}
fullWidth={true}
type="text"
onChange={(e) => setName(e.target.value)}
placeholder="Name..."
tabIndex={1}
value={name}
/>
</div>
<div className="photos-count">({album.photos.length} photos)</div>
</div>
</div>
{apiError && <div className="error-box">{apiError}</div>}
{album && <div>
<div id="album-description">
<div className="input-element">
<TextField multiline={true} fullWidth={true} rows={8}
onChange={(e) => setDescription(e.target.value)}
placeholder="Description..."
tabIndex={2}
value={description}
/>
</div>
</div>
<div className="save-button">
<Button id="page-save" variant="contained" color="info" disabled={!hasChanges()}
onClick={handleSaveEdit}>
Save
</Button>
</div>
{album?.albumId &&
<div id="photo-list">
<div className="photo photo--new">
<div className="cover-photo">
<DragAndDrop onDrop={handleDrop} onClick={(e) => fileInputRef.current.click(e)}>
{!isUploading && <AddAPhoto className="no-photos" color="primary"/>}
<Loader loading={isUploading}/>
</DragAndDrop>
<input type="file" id="photosUpload" ref={fileInputRef} multiple accept="image/*"
onChange={(e) => handleFiles(e.target.files)}></input>
{uploadApiError && <div className="upload-error">{uploadApiError}</div>}
{uploadSuccess && <div className="upload-success">Photo's uploaded successfully</div>}
</div>
</div>
{photosToUpdate?.map((photo, idx) =>
<div className="card-layout photo" key={photo.photoId}>
<div className="cover-photo">
{<img src={`${photo.url}?width=450&height=450&fit=cover`} alt={photo.name}/>}
<div className="file-name">
{photo.fileName}
</div>
</div>
<div className="card-details">
<div className="title input-element">
<TextField fullWidth={true} variant="standard"
value={photo.name}
onChange={(e) => handlePhotoNameChange(idx, e)}
/>
</div>
<div className="description input-element">
<TextField multiline={true} fullWidth={true} rows={8}
onChange={(e) => handlePhotoDescriptionChange(idx, e)}
value={photo.description || ""}
/>
</div>
<div className="input-buttons">
<div className="left-buttons">
<Button className="photo-save" variant="contained" color="error"
onClick={(e) => handleDeletePhoto(e, photo.photoId)}>
Delete
</Button>
<Button className="photo-save" variant="contained" color="info"
disabled={!photoHasChanges(idx)}
onClick={() => handleSavePhotoEdit(idx)}>
Save
</Button>
</div>
<div className="right-buttons">
<Button className="photo-save"
style={{visibility: (idx > 0 ? "visible" : "hidden")}}
startIcon={<MoveUp/>} variant="contained" color="action"
onClick={() => handleMovePhotoUp(idx)}>
Move up
</Button>
<Button className="photo-save"
style={{visibility: (idx < photosToUpdate.length - 1 ? "visible" : "hidden")}}
startIcon={<MoveDown/>} variant="contained" color="action"
onClick={(e) => handleMovePhotoDown(idx)}>
Move down
</Button>
</div>
</div>
</div>
</div>
)}
</div>
}
</div>}
</div>;
}
export default AlbumManager;
+119
View File
@@ -0,0 +1,119 @@
@import "../variables";
#admin-page-manage-albums {
#page-title {
#left-part {
align-items: flex-end;
}
@media (max-width: 768px) {
flex-direction: column;
#left-part {
width: 100%;
flex-wrap: wrap;
button {
width: 50px;
}
#edit-title {
flex: 1;
}
.photos-count {
flex: 1 1 100%;
margin-left: 70px;
}
}
}
}
#album-description {
.MuiFormControl-root {
background: white;
border-radius: $borderRadius;
}
}
.save-button {
margin-top: 15px;
display: flex;
justify-content: flex-end;
}
#photo-list {
margin-top: 25px;
display: grid;
grid-template-columns: 1fr;
gap: 25px;
.photo {
padding: 15px;
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: space-between;
border: 2px solid $borderColor;
border-radius: $borderRadius;
position: relative;
&.photo--new {
border-style: dashed;
background: #fff;
.cover-photo {
height: 150px;
flex: 1;
.draggable-content {
height: 150px;
padding-left: calc(50% - 37.5px);
display: flex;
flex-direction: column;
justify-content: center;
cursor: pointer;
&:hover {
transition: 0.3s;
scale: 1.2
}
#page-loader {
position: absolute;
padding-left: 0;
padding-top: unset;
}
svg {
width: 75px;
height: 75px;
}
}
}
}
.title {
.MuiInput-input {
font-size: 1.4rem;
}
}
@media(max-width: 968px) {
flex-direction: column;
align-items: center;
.photo-details {
margin-left: 0;
width: 100%;
max-width: 450px;
}
}
}
}
}
@@ -0,0 +1,197 @@
import * as React from "react";
import {useRef, useState} from "react";
import {NavLink, useNavigate} from "react-router-dom";
import {Button, IconButton, InputAdornment, TextField} from "@mui/material";
import {useAuth} from "../services/authentication/use-auth";
import {useInstances} from "../services/instances/use-instances";
import {Instance} from "../models/instance";
import {
AddAPhoto, AddCircle,
ArrowRightAltOutlined, RemoveCircle
} from "@mui/icons-material";
import "./instance-selector-page.scss";
import DragAndDrop from "../components/drag-and-drop/drag-and-drop";
export function InstanceSelectorPage() {
const navigate = useNavigate();
const {
instances,
createInstance,
deleteInstance,
updateInstance,
uploadCoverPhoto,
instanceApiError
} = useInstances();
const [instancesToUpdate, setInstancesToUpdate] = useState(instances?.map(inst => new Instance(inst)) || []);
const {userData} = useAuth();
const [newInstanceTitle, setNewInstanceTitle] = useState("");
// useEffect(() => {
// setInstancesToUpdate(instances);
// }, [instances]);
let onNewInstanceTitleChanged = (e) => {
setNewInstanceTitle(e.target.value);
}
const handleInstanceTitleChange = (e, idx) => {
instancesToUpdate[idx].title = e.target.value;
setInstancesToUpdate([...instancesToUpdate]);
}
const handleInstanceSubtitleChange = (e, idx) => {
instancesToUpdate[idx].subtitle = e.target.value;
setInstancesToUpdate([...instancesToUpdate]);
}
const handleNewInstanceUrlChange = (e, idx) => {
instancesToUpdate[idx].newInstanceUrl = e.target.value;
setInstancesToUpdate([...instancesToUpdate]);
}
const handleAddNewInstanceUrl = (e, idx) => {
instancesToUpdate[idx].urls.push(instancesToUpdate[idx].newInstanceUrl);
instancesToUpdate[idx].newInstanceUrl = "";
setInstancesToUpdate([...instancesToUpdate]);
}
const handleDeleteUrl = (e, idx, url) => {
instancesToUpdate[idx].urls = instancesToUpdate[idx].urls.filter(u => u !== url);
setInstancesToUpdate([...instancesToUpdate]);
}
const instanceHasChanges = (idx) => {
return (instancesToUpdate[idx].title || "") !== (instances[idx].title || "") ||
(instancesToUpdate[idx].subtitle || "") !== (instances[idx].subtitle || "") ||
JSON.stringify(instancesToUpdate[idx].urls) !== JSON.stringify(instances[idx].urls);
}
const handleCreateInstance = async () => {
await createInstance(new Instance({
title: newInstanceTitle
}));
}
const handleSaveInstance = async (idx) => {
if (instanceHasChanges(idx)) {
instancesToUpdate[idx] = await updateInstance(instancesToUpdate[idx]);
setInstancesToUpdate([...instancesToUpdate]);
}
}
const handleDeleteInstance = async (e, instanceId) => {
await deleteInstance(instanceId);
}
const [isUploading, setIsUploading] = useState({});
const fileInputRefs = useRef([]);
const handleFiles = async (event, idx) => {
setIsUploading({...isUploading, [idx]: true});
const formData = new FormData();
formData.append('cover-photo', event.target.files[0]);
await uploadCoverPhoto(instancesToUpdate[idx].instanceId, formData);
setIsUploading({...isUploading, [idx]: false});
}
const handleDrop = async (e, idx) => {
await handleFiles(e, idx)
}
return <div id="instance-selector-page">
{instanceApiError && <div className="error-box">{instanceApiError}</div>}
<div>
{userData?.profile?.resource_access?.photos?.roles?.indexOf("admin") >= 0 && <>
<div id="edit-title" className="input-element">
<TextField fullWidth={true} variant="standard"
value={newInstanceTitle}
placeholder="Instance..."
onChange={onNewInstanceTitleChanged}
/>
</div>
<div className="input-buttons">
<div className="right-buttons">
<Button className="save" variant="contained" color="info"
onClick={handleCreateInstance}>
Create instance
</Button>
</div>
</div>
</>}
</div>
<div id="instance-list">
{instancesToUpdate?.map((instance, idx) =>
<div className="card-layout instance" key={instance.instanceId}>
<div
className={`${idx} cover-photo ${(!isUploading[idx] && instance.coverPhoto ? "" : "cover-photo--no-img")}`}>
<DragAndDrop onDrop={(e) => handleDrop(e, idx)}
onClick={(e) => fileInputRefs.current[idx].click(e)}
isLoading={isUploading[idx]}>
{!isUploading[idx] && instance.coverPhoto
? <img src={`${instance.coverPhoto.url}?width=450&height=450&fit=cover`}
alt={instance.coverPhoto.name}/>
: <AddAPhoto className="no-photos"/>
}
</DragAndDrop>
<input type="file" id={`photosUpload`} ref={(element) => fileInputRefs.current[idx] = element}
accept="image/*"
onChange={(e) => handleFiles(e, idx)}></input>
</div>
<div className="card-details">
<div className="title input-element">
<TextField fullWidth={true} variant="standard"
value={instance.title}
placeholder="Title..."
onChange={(e) => handleInstanceTitleChange(e, idx)}
/>
</div>
<div className="description input-element">
<TextField fullWidth={true} variant="standard"
value={instance.subtitle || ""}
placeholder="Subtitle..."
onChange={(e) => handleInstanceSubtitleChange(e, idx)}
/>
</div>
<div className="url input-element">
<TextField fullWidth={true} variant="standard"
value={instance.newInstanceUrl || ""}
placeholder="https://my-photos.be"
onChange={(e) => handleNewInstanceUrlChange(e, idx)}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton color="warning"
onClick={(e) => handleAddNewInstanceUrl(e, idx)}>
<AddCircle/>
</IconButton>
</InputAdornment>
)
}}/>
<div className="instance-links">
{instance.urls.map(url => <div className="instance-link">
<IconButton color="error" onClick={(e) => handleDeleteUrl(e, idx, url)}>
<RemoveCircle />
</IconButton>
<NavLink to={url}>{url}</NavLink>
</div>)}
</div>
</div>
<div className="input-buttons">
<div className="left-buttons">
<Button className="photo-save" variant="contained" color="error"
onClick={(e) => handleDeleteInstance(e, instance.instanceId)}>
Delete
</Button>
<Button className="photo-save" variant="contained" color="info"
disabled={!instanceHasChanges(idx)}
onClick={() => handleSaveInstance(idx)}>
Save
</Button>
</div>
<div className="right-buttons">
<Button className="photo-save" variant="contained" color="info"
onClick={() => navigate(`/${instance.instanceId}`)}>
Albums <ArrowRightAltOutlined/>
</Button>
</div>
</div>
</div>
</div>
)}
</div>
</div>;
}
@@ -0,0 +1,46 @@
@import "../variables";
#instance-selector-page {
#instance-list {
margin-top: 25px;
display: grid;
grid-template-columns: 1fr;
gap: 25px;
> .instance {
.title {
display: flex;
.MuiFormControl-root {
margin-right: 50px;
}
.MuiInput-input {
font-size: 1.4rem;
}
.MuiButtonBase-root {
flex: 0 0 100px;
}
}
.instance-links {
margin-top: 10px;
}
div.instance-link {
padding: 2px 0;
a {
color: $accentColor;
text-decoration: none;
}
}
@media(max-width: 968px) {
flex-direction: column;
align-items: center;
}
}
}
}
@@ -0,0 +1,82 @@
import {Button, TextField} from "@mui/material";
import * as React from "react";
import {useState} from "react";
import useInstancesApi from "../services/instances/instances-api";
import {useInstances} from "../services/instances/use-instances";
export function ManageInstancePage() {
const {selectedInstance, setSelectedInstance} = useInstances();
const {updateInstance} = useInstancesApi();
let [instanceToUpdate, setInstanceToUpdate] = useState({});
const [apiError, setApiError] = useState(null);
const handleTitleChange = (e) => {
instanceToUpdate.title = e.target.value;
setInstanceToUpdate(instanceToUpdate);
}
const handleSubtitleChange = (e) => {
instanceToUpdate.subtitle = e.target.value;
setInstanceToUpdate(instanceToUpdate);
}
const instanceHasChanges = () => {
return (instanceToUpdate.title || "") !== (selectedInstance.title || "") ||
(instanceToUpdate.subtitle || "") !== (selectedInstance.subtitle || "");
}
const handleSaveInstance = async () => {
try {
setApiError(null);
if (instanceHasChanges()) {
const updatedInstance = await updateInstance(
instanceToUpdate
);
setSelectedInstance(updatedInstance);
setInstanceToUpdate(updatedInstance);
}
} catch (err) {
console.error(err);
setApiError("An error occurred trying to save the instance, contact the system administrator");
}
}
return <>
<div className="card-layout photo">
{apiError && <div className="error-box">{apiError}</div>}
{selectedInstance && <>
{selectedInstance.photo &&
<div className="cover-photo">
{<img src={`${selectedInstance.photo.url}?width=450&height=450&fit=cover`}
alt={selectedInstance.photo.name}/>}
<div className="file-name">
{selectedInstance.photo.fileName}
</div>
</div>
}
<div className="card-details">
<div className="title input-element">
<TextField fullWidth={true} variant="standard"
value={selectedInstance.title}
onChange={handleTitleChange}
/>
</div>
<div className="description input-element">
<TextField multiline={true} fullWidth={true} rows={8}
onChange={handleSubtitleChange}
value={selectedInstance.subtitle || ""}
/>
</div>
<div className="input-buttons">
<div className="left-buttons">
<Button className="photo-save" variant="contained" color="info"
disabled={!instanceHasChanges()}
onClick={handleSaveInstance}>
Save
</Button>
</div>
</div>
</div>
</>}
</div>
</>;
}
+3
View File
@@ -0,0 +1,3 @@
export function NoAccessPage() {
return <h1>No access!</h1>
}
+210
View File
@@ -0,0 +1,210 @@
import * as React from "react";
import {useCallback, useEffect, useRef, useState} from "react";
import useAlbumsApi from "../services/albums-api";
import {
AddAPhoto,
KeyboardArrowRight,
MoveDown,
MoveUp
} from "@mui/icons-material";
import {Link, useNavigate} from "react-router-dom";
import Loader from "../components/Loader";
import {Button, TextField} from "@mui/material";
import {Album} from "../models/album";
import "./portfolio-manager.scss"
import {useInstances} from "../services/instances/use-instances";
function PortfolioManager() {
const navigate = useNavigate();
const {selectedInstance} = useInstances();
const [apiError, setApiError] = useState(null);
const {fetchAlbums, patchAlbum, deleteAlbum, moveAlbumDown, moveAlbumUp} = useAlbumsApi();
const [albums, setAlbums] = useState(null);
const [isLoading, setIsLoading] = useState(false);
const [albumsToUpdate, setAlbumsToUpdate] = useState(null);
const fetchedRef = useRef(false)
const fetchData = useCallback(async () => {
try {
setIsLoading(true);
setApiError(null);
const albums = await fetchAlbums(selectedInstance);
setAlbums(albums);
setAlbumsToUpdate(albums.map(a => new Album(a)));
} catch (err) {
console.error("Could not fetch albums", err);
setApiError("An error occurred trying to fetch your albums, please contact the system administrator");
setAlbums([]);
} finally {
setIsLoading(false);
}
}, [fetchAlbums, selectedInstance])
useEffect(() => {
if (selectedInstance && !fetchedRef.current) {
fetchData();
fetchedRef.current = true;
}
}, [selectedInstance, fetchData]);
const handleAlbumNameChange = (albumIndex, e) => {
albumsToUpdate[albumIndex].name = e.target.value;
setAlbumsToUpdate([...albumsToUpdate]);
}
const handleAlbumDescriptionChange = (albumIndex, e) => {
albumsToUpdate[albumIndex].description = e.target.value;
setAlbumsToUpdate([...albumsToUpdate]);
}
const albumHasChanges = (idx) => {
return albumsToUpdate[idx].name !== albums[idx].name
|| albumsToUpdate[idx].description !== albums[idx].description;
}
const handleSaveAlbum = async (idx) => {
try {
setIsLoading(true);
if (albumHasChanges(idx)) {
albumsToUpdate[idx] = await patchAlbum(selectedInstance, albumsToUpdate[idx]);
setAlbums([
...albumsToUpdate.map(a => new Album(a))
]);
}
} catch (err) {
if (typeof err === "string" && err.toLowerCase().indexOf("duplicate") >= 0) {
setApiError("Name already in use");
} else {
console.error(err);
navigate(`/${selectedInstance.instanceId}/albums`, {state: {error: err}});
}
} finally {
setIsLoading(false);
}
}
const handleDeleteAlbum = async (e, albumId) => {
try {
setIsLoading(true);
const updatedAlbums = await deleteAlbum(selectedInstance, albumId);
setAlbums(updatedAlbums);
setAlbumsToUpdate(albumsToUpdate.filter(a => a.albumId !== albumId));
} catch (err) {
console.error(err);
} finally {
setIsLoading(false);
}
}
const handleMoveAlbumDown = async (idx) => {
try {
setIsLoading(true);
const updatedAlbums = await moveAlbumDown(selectedInstance, albumsToUpdate[idx].albumId);
const origAlbum = albumsToUpdate[idx];
albumsToUpdate[idx] = albumsToUpdate[idx + 1];
albumsToUpdate[idx + 1] = origAlbum;
setAlbums(updatedAlbums);
} catch (err) {
console.error(err);
} finally {
setIsLoading(false);
}
}
const handleMoveAlbumUp = async (idx) => {
try {
setIsLoading(true);
const updatedAlbums = await moveAlbumUp(selectedInstance, albumsToUpdate[idx].albumId);
const origAlbum = albumsToUpdate[idx];
albumsToUpdate[idx] = albumsToUpdate[idx - 1];
albumsToUpdate[idx - 1] = origAlbum;
setAlbums(updatedAlbums);
} catch (err) {
console.error(err);
} finally {
setIsLoading(false);
}
}
return <div id="portfolio-manager">
<div id="page-title">
<div id="left-part">
<div className="title">Albums</div>
{albums && <div className="photos-count">({albums.length} albums)</div>}
</div>
<div id="right-part">
<Button id="page-save" variant="contained" color="success"
onClick={() => navigate("new", {state: {album: new Album()}})}>
Create
</Button>
</div>
</div>
{apiError && <div className="error-box">{apiError}</div>}
{!apiError && albums?.length > 0 && <div id="album-list">
{albumsToUpdate.map((album, idx) =>
<div className="card-layout album-list-item" key={album.albumId}>
<div className={`cover-photo ${(album.coverPhoto ? "" : "cover-photo--no-img")}`}
onClick={() => navigate(album.albumId, {state: {album: albums[idx]}})}>
{album.coverPhoto
? <img src={`${album.coverPhoto.url}?width=450&height=450&fit=cover`}
alt={album.coverPhoto.name}/>
: <AddAPhoto className="no-photos"/>
}
</div>
<div className="card-details">
<div className="title input-element">
<TextField fullWidth={true} variant="standard"
value={album.name} onChange={(e) => handleAlbumNameChange(idx, e)}
/>
<Button endIcon={<KeyboardArrowRight/>} variant="contained"
color="secondary"
onClick={() => navigate(album.albumId, {state: {album: albums[idx]}})}>
Photos
</Button>
</div>
<div className="description input-element">
<TextField multiline={true} fullWidth={true} rows={8}
onChange={(e) => handleAlbumDescriptionChange(idx, e)}
value={album.description || ""}
/>
</div>
<div className="input-buttons">
<div className="left-buttons">
<Button className="photo-save" variant="contained" color="error"
onClick={(e) => handleDeleteAlbum(e, album.albumId)}>
Delete
</Button>
<Button className="photo-save" variant="contained" color="info" disabled={!albumHasChanges(idx)}
onClick={() => handleSaveAlbum(idx)}>
Save
</Button>
</div>
<div className="right-buttons">
<Button
style={{visibility: (idx > 0 ? "visible" : "hidden")}}
startIcon={<MoveUp/>} variant="contained" color="action"
onClick={() => handleMoveAlbumUp(idx)}>
Move up
</Button>
<Button
style={{visibility: (idx < albumsToUpdate.length - 1 ? "visible" : "hidden")}}
startIcon={<MoveDown/>} variant="contained" color="action"
onClick={(e) => handleMoveAlbumDown(idx)}>
Move down
</Button>
</div>
</div>
</div>
</div>
)}
</div>}
{!apiError && albums?.length === 0 && <div><Link to="new" state={{album: new Album()}}>Click here</Link> to create your first album!</div>}
<Loader loading={isLoading} />
</div>
}
export default PortfolioManager;
@@ -0,0 +1,41 @@
@import "../variables";
#portfolio-manager {
#page-title {
#left-part {
align-items: first baseline;
}
}
#album-list {
margin-top: 25px;
display: grid;
grid-template-columns: 1fr;
gap: 25px;
> .album-list-item {
.title {
display: flex;
.MuiFormControl-root {
margin-right: 50px;
}
.MuiInput-input {
font-size: 1.4rem;
}
.MuiButtonBase-root {
flex: 0 0 100px;
}
}
@media(max-width: 968px) {
flex-direction: column;
align-items: center;
}
}
}
}
+134
View File
@@ -0,0 +1,134 @@
import useAuthorizedApi from "./authorized-api";
import {Album} from "../models/album";
const apiUrl = window._env_.REACT_APP_API_URL;
export default function useAlbumsApi() {
const {get, post, patch, doDelete} = useAuthorizedApi();
return {
fetchAlbums,
postAlbum,
patchAlbum,
deleteAlbum,
moveAlbumDown,
moveAlbumUp,
patchPhoto,
uploadPhotos,
deletePhoto,
movePhotoDown,
movePhotoUp,
};
/**
* @return {Promise<Album[]>}
*/
async function fetchAlbums(instance) {
const response = await get(`${apiUrl}/api/instances/${instance.instanceId}/albums`);
return response.data.map(album => new Album(album));
}
/**
* @param {Instance} instance
* @param {Album} album
* @return {Promise<Album>}
*/
async function patchAlbum(instance, album) {
const patches = [];
if (album.name) {
patches.push({op: "replace", path: "/name", value: album.name})
}
if (album.description) {
patches.push({op: "replace", path: "/description", value: album.description})
}
if (album.sort) {
patches.push({op: "replace", path: "/sort", value: album.sort})
}
if (patches.length === 0) {
return null;
}
const response = await patch(
`${apiUrl}/api/instances/${instance.instanceId}/albums/${album.albumId}`,
patches
);
return new Album(response.data);
}
/**
* @param {Instance} instance
* @param {Album} album
* @return {Promise<Album>}
*/
async function postAlbum(instance, album) {
const response = await post(
`${apiUrl}/api/instances/${instance.instanceId}/albums`, {
name: album.name,
description: album.description,
sort: album.sort
}
);
return new Album(response.data);
}
async function deleteAlbum(instance, albumId) {
const response = await doDelete(`${apiUrl}/api/instances/${instance.instanceId}/albums/${albumId}`);
return response.data.map(a => new Album(a));
}
async function moveAlbumDown(instance, albumId) {
const response = await post(`${apiUrl}/api/instances/${instance.instanceId}/albums/${albumId}/move-down`);
return response.data.map(a => new Album(a));
}
async function moveAlbumUp(instance, albumId) {
const response = await post(`${apiUrl}/api/instances/${instance.instanceId}/albums/${albumId}/move-up`);
return response.data.map(a => new Album(a));
}
/**
* @param {Instance} instance
* @param {string} albumId
* @param {Photo} photo
* @return {Promise<Album>}
*/
async function patchPhoto(instance, albumId, photo) {
const patches = [];
if (photo.name) {
patches.push({op: "replace", path: "/name", value: photo.name})
}
if (photo.description) {
patches.push({op: "replace", path: "/description", value: photo.description})
}
if (patches.length === 0) {
return null;
}
const response = await patch(
`${apiUrl}/api/instances/${instance.instanceId}/albums/${albumId}/photos/${photo.photoId}`,
patches
);
return new Album(response.data);
}
async function uploadPhotos(instance, albumId, formData) {
const response = await post(
`${apiUrl}/api/instances/${instance.instanceId}/albums/${albumId}/upload-photos`,
formData
);
return new Album(response.data);
}
async function deletePhoto(instance, albumId, photoId) {
const response = await doDelete(`${apiUrl}/api/instances/${instance.instanceId}/albums/${albumId}/photos/${photoId}`);
return new Album(response.data);
}
async function movePhotoDown(instance, albumId, photoId) {
const response = await post(`${apiUrl}/api/instances/${instance.instanceId}/albums/${albumId}/photos/${photoId}/move-down`);
return new Album(response.data);
}
async function movePhotoUp(instance, albumId, photoId) {
const response = await post(`${apiUrl}/api/instances/${instance.instanceId}/albums/${albumId}/photos/${photoId}/move-up`);
return new Album(response.data);
}
}
@@ -0,0 +1,236 @@
import React, {
useState,
useEffect,
useRef,
useMemo,
useCallback,
} from 'react';
import {
UserManager,
} from 'oidc-client-ts';
export const AuthContext = React.createContext(
undefined
);
/**
* @private
* @hidden
* @param {Location} location
*/
export const hasCodeInUrl = (location) => {
const searchParams = new URLSearchParams(location.search);
const hashParams = new URLSearchParams(location.hash.replace('#', '?'));
return (
searchParams.has('code') ??
searchParams.has('id_token') ??
searchParams.has('session_state') ??
hashParams.has('code') ??
hashParams.has('id_token') ??
hashParams.has('session_state')
);
};
/**
* @private
* @hidden
* @param props
*/
export const initUserManager = (props) => {
if (props.userManager) {
return props.userManager;
}
const {
authority,
clientId,
clientSecret,
redirectUri,
silentRedirectUri,
postLogoutRedirectUri,
responseType,
scope,
automaticSilentRenew,
loadUserInfo,
popupWindowFeatures,
popupRedirectUri,
popupWindowTarget,
extraQueryParams,
metadata,
} = props;
return new UserManager({
authority: authority ?? '',
client_id: clientId ?? '',
client_secret: clientSecret,
redirect_uri: redirectUri ?? '',
silent_redirect_uri: silentRedirectUri ?? redirectUri,
post_logout_redirect_uri: postLogoutRedirectUri ?? redirectUri,
response_type: responseType ?? 'code',
scope: scope ?? 'authentication',
loadUserInfo: loadUserInfo ?? true,
popupWindowFeatures: popupWindowFeatures,
popup_redirect_uri: popupRedirectUri,
popupWindowTarget: popupWindowTarget,
automaticSilentRenew,
extraQueryParams,
metadata: metadata,
});
};
/**
*
* @param children
* @param autoSignIn
* @param autoSignInArgs
* @param autoSignOut
* @param autoSignOutArgs
* @param onBeforeSignIn
* @param onSignIn
* @param onSignOut
* @param location
* @param onSignInError
* @param props AuthProviderProps
*/
export const AuthProvider = ({
children,
autoSignIn = true,
autoSignInArgs,
autoSignOut = true,
autoSignOutArgs,
onBeforeSignIn,
onSignIn,
onSignOut,
location = window.location,
onSignInError,
...props
}) => {
const [isLoading, setIsLoading] = useState(true);
const [userData, setUserData] = useState(null);
const [userManager] = useState(() => initUserManager(props));
const isMountedRef = useRef(false);
const signOutHooks = useCallback(async ()=> {
setUserData(null);
if (onSignOut) {
await onSignOut();
}
}, [onSignOut]);
const signInPopupHooks = useCallback(async ()=> {
const userFromPopup = await userManager.signinPopup();
setUserData(userFromPopup);
if (onSignIn) {
await onSignIn(userFromPopup);
}
await userManager.signinPopupCallback();
}, [userManager, onSignIn]);
/**
* Handles user auth flow on initial render.
*/
useEffect(() => {
let isMounted = true;
isMountedRef.current = true;
setIsLoading(true);
void (async () => {
if (!userManager) {
return;
}
const user = await userManager.getUser();
// isMountedRef cannot be used here as its value is updated by next useEffect.
// We intend to keep context of current useEffect.
if (isMounted && (!user || user.expired)) {
// If the user is returning back from the OIDC provider, get and set the user data.
if (hasCodeInUrl(location)) {
try {
const user = await userManager.signinCallback();
if (user) {
setUserData(user);
if (onSignIn) {
await onSignIn(user);
}
}
} catch (error) {
if (onSignInError) {
onSignInError(error);
} else {
throw error;
}
}
}
// If autoSignIn is enabled, redirect to the OIDC provider.
else if (autoSignIn) {
const state = onBeforeSignIn ? onBeforeSignIn() : undefined;
await userManager.signinRedirect({ ...autoSignInArgs, state });
}
}
// Otherwise if the user is already signed in, set the user data.
else if (isMountedRef.current) {
setUserData(user);
}
setIsLoading(false);
})();
return () => {
isMounted = false;
isMountedRef.current = false;
};
}, [
location,
userManager,
autoSignIn,
onBeforeSignIn,
onSignIn,
onSignInError,
autoSignInArgs,
]);
/**
* Registers UserManager event callbacks for handling changes to user state due to automaticSilentRenew, session expiry, etc.
*/
useEffect(() => {
const updateUserData = (user) => {
if (isMountedRef.current) {
setUserData(user);
}
};
const onSilentRenewError =
async () => {
if (autoSignOut) {
await signOutHooks();
await userManager.signoutRedirect(autoSignOutArgs);
}
};
userManager.events.addUserLoaded(updateUserData);
userManager.events.addSilentRenewError(onSilentRenewError);
return () => {
userManager.events.removeUserLoaded(updateUserData);
userManager.events.removeSilentRenewError(onSilentRenewError);
};
}, [userManager, autoSignOut, signOutHooks, autoSignOutArgs]);
const value = useMemo(() => {
return {
signIn: async (args) => {
await userManager.signinRedirect(args);
},
signInPopup: async () => {
await signInPopupHooks();
},
signOut: async ()=> {
await userManager.removeUser();
await signOutHooks();
},
signOutRedirect: async (args) => {
await userManager.signoutRedirect(args);
await signOutHooks();
},
userManager,
userData,
isLoading,
};
}, [userManager, isLoading, userData, signInPopupHooks, signOutHooks]);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};
+17
View File
@@ -0,0 +1,17 @@
import {AuthProvider} from "./auth-context";
function keycloak({children}) {
return <AuthProvider
authority="https://keycloak.novox.be/realms/Novox"
autoSignIn={false}
scope="openid roles"
loadUserInfo={true}
clientId="photos"
silentRedirectUri={`${window.location.protocol}//${window.location.host}/admin/signin-keycloak`}
redirectUri={`${window.location.protocol}//${window.location.host}/admin/signin-keycloak`}
postLogoutRedirectUri={`${window.location.protocol}//${window.location.host}/admin/signout-keycloak`}
>
{children}
</AuthProvider>;
}
export default keycloak;
@@ -0,0 +1,14 @@
import { useContext } from 'react';
import { AuthContext } from './auth-context';
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error(
'AuthProvider context is undefined, please verify you are calling useAuth() as child of a <AuthProvider> component.',
);
}
return context;
};
@@ -0,0 +1,69 @@
import {useAuth} from "./authentication/use-auth";
export default function useAuthorizedApi() {
const {userData, signOut} = useAuth();
return {
get: request('GET'),
post: request('POST'),
put: request('PUT'),
patch: request('PATCH'),
doDelete: request('DELETE')
};
/**
* @param {string} method
* @return {function(*, *): Promise<*>}
*/
function request(method) {
/**
* @param {string} url
* @param {?any} [body]
*/
return async (url, body) => {
/** @type {RequestInit} **/
const requestOptions = {
method,
headers: authHeader(url)
};
if (body) {
if (body instanceof FormData) {
requestOptions.body = body;
} else {
requestOptions.headers['Content-Type'] = 'application/json';
requestOptions.body = JSON.stringify(body);
}
}
return handleResponse(await fetch(url, requestOptions));
}
}
function authHeader(url) {
// return auth header with jwt if user is logged in and request is to the api url
const token = userData?.access_token;
const isLoggedIn = !!token;
const isApiUrl = url.startsWith(window._env_.REACT_APP_API_URL);
if (isLoggedIn && isApiUrl) {
return {Authorization: `Bearer ${token}`};
} else {
return {};
}
}
async function handleResponse(response) {
const text = await response.text()
const data = text && JSON.parse(text);
if (!response.ok) {
if ([401, 403].includes(response.status) && userData?.access_token) {
// auto logout if 401 Unauthorized or 403 Forbidden response returned from api
localStorage.removeItem('user');
await signOut();
}
const error = (data && data.message) || response.statusText;
return Promise.reject(error);
}
return data;
}
}
@@ -0,0 +1,63 @@
import useAuthorizedApi from "../authorized-api";
import {Instance} from "../../models/instance";
import {useState} from "react";
const apiUrl = window._env_.REACT_APP_API_URL;
export default function useInstancesApi() {
const {get, post, put, doDelete} = useAuthorizedApi();
const [instanceApiError, setInstanceApiError] = useState(null);
const tryApiCall = async (func) => {
try {
return await func();
} catch (err) {
console.error(err);
if (typeof err === "string" && err.toLowerCase().indexOf("already exist") >= 0) {
setInstanceApiError(err);
} else if (typeof err === "string" && err.toLowerCase().indexOf("mimetype") >= 0) {
setInstanceApiError(err);
} else {
setInstanceApiError("The API is currently unavailable, please contact the administrator");
}
return null;
}
}
const fetchInstances = async () => tryApiCall(async () => {
const response = await get(`${apiUrl}/api/instances`);
return response?.data.map(inst => new Instance(inst));
});
const updateInstance = async (instance) => tryApiCall(async () => {
const response = await put(`${apiUrl}/api/instances/${instance.instanceId}`, instance);
return new Instance(response.data);
});
const deleteInstance = async (instanceId) => tryApiCall(async () => {
const response = await doDelete(`${apiUrl}/api/instances/${instanceId}`);
return new Instance(response.data);
});
const createInstance = (instance) => tryApiCall(async () => {
const response = await post(`${apiUrl}/api/instances`, instance);
return new Instance(response.data);
});
const uploadCoverPhoto = (instanceId, formData) => tryApiCall(async () => {
const response = await post(
`${apiUrl}/api/instances/${instanceId}/upload-cover-photo`,
formData
);
return new Instance(response.data);
});
return {
fetchInstances,
updateInstance,
deleteInstance,
createInstance,
uploadCoverPhoto,
instanceApiError,
};
}
@@ -0,0 +1,78 @@
import React, {useEffect, useState} from "react";
import useInstancesApi from "./instances-api";
import {useAuth} from "../authentication/use-auth";
import {useLocation, useNavigate} from "react-router-dom";
import {Instance} from "../../models/instance";
export const InstancesContext = React.createContext(
undefined
);
export const InstancesProvider = ({children}) => {
const navigate = useNavigate();
const {pathname} = useLocation();
const {userData} = useAuth();
const {fetchInstances, createInstance, deleteInstance, updateInstance, uploadCoverPhoto, instanceApiError} = useInstancesApi();
const [isLoading, setIsLoading] = useState(true);
const [instances, setInstances] = useState(undefined);
const [selectedInstance, setSelectedInstance] = useState(null);
useEffect(() => {
void (async () => {
if (userData && fetchInstances) {
if (instances === undefined) {
setIsLoading(true);
const instances = await fetchInstances();
setInstances(instances);
setIsLoading(false);
} else {
const possibleSelectedInstance = pathname.split('/')[1];
if(possibleSelectedInstance){
const selectedInstance = instances?.find(inst => inst.instanceId === possibleSelectedInstance);
if (selectedInstance) {
setSelectedInstance(selectedInstance);
} else {
navigate("/");
}
} else {
setSelectedInstance(null);
}
}
}
})();
}, [navigate, pathname, userData, instances, fetchInstances]);
const handleCreateInstance = async (instance) => {
const newInstance = await createInstance(instance);
setInstances([...instances, newInstance]);
}
const handleUpdateInstance = async (instance) => {
const updatedInstance = await updateInstance(instance);
const idx = instances.findIndex(i => i.instanceId === updatedInstance.instanceId);
instances[idx] = updatedInstance;
setInstances([...instances]);
return updatedInstance;
}
const handleUpdateCoverPhoto = async (instanceId, formData) => {
const updatedInstance = await uploadCoverPhoto(instanceId, formData);
const idx = instances.findIndex(i => i.instanceId === updatedInstance.instanceId);
instances[idx] = updatedInstance;
setInstances(instances.map(i => new Instance(i)));
return updatedInstance;
}
const value = {
isLoading,
instances,
selectedInstance,
setSelectedInstance,
createInstance: handleCreateInstance,
deleteInstance,
updateInstance: handleUpdateInstance,
uploadCoverPhoto: handleUpdateCoverPhoto,
instanceApiError,
};
return <InstancesContext.Provider value={value}>{children}</InstancesContext.Provider>;
};
@@ -0,0 +1,17 @@
import {useContext} from "react";
import {InstancesContext} from "./instances-context";
/**
* @return {InstancesContext}
*/
export const useInstances = () => {
const context = useContext(InstancesContext);
if (!context) {
throw new Error(
'Instances context is undefined, please verify you are calling useInstances() as child of a <InstancesProvider> 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;