Initial commit
This commit is contained in:
Executable
+1
@@ -0,0 +1 @@
|
||||
REACT_APP_API_URL=http://localhost:9001
|
||||
Executable
+27
@@ -0,0 +1,27 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Temporary env files
|
||||
/public/env-config.js
|
||||
env-config.js
|
||||
@@ -0,0 +1,26 @@
|
||||
FROM nginx:1.15.2-alpine
|
||||
|
||||
# Nginx config
|
||||
RUN rm -rf /etc/nginx/conf.d
|
||||
COPY ./conf /etc/nginx
|
||||
|
||||
# Static build
|
||||
COPY ./build /usr/share/nginx/html/
|
||||
|
||||
# Default port exposure
|
||||
EXPOSE 80
|
||||
|
||||
# Copy .env file and shell script to container
|
||||
WORKDIR /usr/share/nginx/html
|
||||
|
||||
COPY scripts/env.sh .
|
||||
COPY .env .
|
||||
|
||||
# Add bash
|
||||
RUN apk add --no-cache bash
|
||||
|
||||
# Make our shell script executable
|
||||
RUN chmod +x env.sh
|
||||
|
||||
# Start Nginx server
|
||||
CMD ["/bin/bash", "-c", "/usr/share/nginx/html/env.sh && nginx -g \"daemon off;\""]
|
||||
Executable
+13
@@ -0,0 +1,13 @@
|
||||
server {
|
||||
listen 80;
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html index.htm;
|
||||
try_files $uri $uri/ /index.html;
|
||||
expires -1; # Set it to different value depending on your standard requirements
|
||||
}
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
location = /50x.html {
|
||||
root /usr/share/nginx/html;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
gzip on;
|
||||
gzip_http_version 1.0;
|
||||
gzip_comp_level 5; # 1-9
|
||||
gzip_min_length 256;
|
||||
gzip_proxied any;
|
||||
gzip_vary on;
|
||||
|
||||
# MIME-types
|
||||
gzip_types
|
||||
application/atom+xml
|
||||
application/javascript
|
||||
application/json
|
||||
application/rss+xml
|
||||
application/vnd.ms-fontobject
|
||||
application/x-font-ttf
|
||||
application/x-web-app-manifest+json
|
||||
application/xhtml+xml
|
||||
application/xml
|
||||
font/opentype
|
||||
image/svg+xml
|
||||
image/x-icon
|
||||
text/css
|
||||
text/plain
|
||||
text/x-component;
|
||||
Generated
+17850
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "admin-app",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.11.1",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@fontsource/roboto": "^5.0.8",
|
||||
"@mui/icons-material": "^5.14.19",
|
||||
"@mui/material": "^5.14.19",
|
||||
"keycloak-js": "^23.0.0",
|
||||
"oidc-client-ts": "^3.0.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.20.0",
|
||||
"react-scripts": "5.0.1",
|
||||
"sass": "^1.69.5"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "npm run start",
|
||||
"start": "scripts/env.sh ./public/env-config.js && react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"eject": "react-scripts eject",
|
||||
"docker-build": "./scripts/docker-build.sh",
|
||||
"docker-run": "./scripts/docker-run.sh"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/plugin-proposal-private-property-in-object": "^7.21.11"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 7.6 KiB |
@@ -0,0 +1,45 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<script src="%PUBLIC_URL%/env-config.js"></script>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Web site created using create-react-app"
|
||||
/>
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||
<!--
|
||||
manifest.json provides metadata used when your web app is installed on a
|
||||
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||
-->
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<!--
|
||||
Notice the use of %PUBLIC_URL% in the tags above.
|
||||
It will be replaced with the URL of the `public` folder during the build.
|
||||
Only files inside the `public` folder can be referenced from the HTML.
|
||||
|
||||
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||
work correctly both with client-side routing and a non-root public URL.
|
||||
Learn how to configure a non-root public URL by running `npm run build`.
|
||||
-->
|
||||
|
||||
<title>Novox Photos App</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<!--
|
||||
This HTML file is a template.
|
||||
If you open it directly in the browser, you will see an empty page.
|
||||
|
||||
You can add webfonts, meta tags, or analytics to this file.
|
||||
The build step will place the bundled scripts into the <body> tag.
|
||||
|
||||
To begin the development, run `npm start` or `yarn start`.
|
||||
To create a production bundle, use `npm run build` or `yarn build`.
|
||||
-->
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"short_name": "React App",
|
||||
"name": "Create React App Sample",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "logo192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "logo512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
||||
Executable
+1
@@ -0,0 +1 @@
|
||||
docker build . -t photos-admin-client
|
||||
Executable
+9
@@ -0,0 +1,9 @@
|
||||
#/bin/bash
|
||||
|
||||
image_name=photos-admin-client
|
||||
container_name=photos-admin-client
|
||||
|
||||
docker stop $container_name
|
||||
docker rm $container_name
|
||||
|
||||
docker run -d -p 3000:80 -e REACT_APP_API_URL=https://photos-api.novox.be --name $container_name $image_name
|
||||
Executable
+32
@@ -0,0 +1,32 @@
|
||||
#!/bin/bash
|
||||
|
||||
envConfigFile=${1:-./env-config.js}
|
||||
|
||||
|
||||
# Recreate config file
|
||||
rm -rf $envConfigFile
|
||||
touch $envConfigFile
|
||||
|
||||
# Add assignment
|
||||
echo "window._env_ = {" >> $envConfigFile
|
||||
|
||||
# Read each line in .env file
|
||||
# Each line represents key=value pairs
|
||||
while read -r line || [[ -n "$line" ]];
|
||||
do
|
||||
# Split env variables by character `=`
|
||||
if printf '%s\n' "$line" | grep -q -e '='; then
|
||||
varname=$(printf '%s\n' "$line" | sed -e 's/=.*//')
|
||||
varvalue=$(printf '%s\n' "$line" | sed -e 's/^[^=]*=//')
|
||||
fi
|
||||
|
||||
# Read value of current variable if exists as Environment variable
|
||||
value=$(printf '%s\n' "${!varname}")
|
||||
# Otherwise use value from .env file
|
||||
[[ -z $value ]] && value=${varvalue}
|
||||
|
||||
# Append configuration property to JS file
|
||||
echo " $varname: \"$value\"," >> $envConfigFile
|
||||
done < .env
|
||||
|
||||
echo "}" >> $envConfigFile
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import {createAnimation} from "../utils/animation";
|
||||
|
||||
const sync = createAnimation(
|
||||
"SyncLoader",
|
||||
`33% {transform: translateY(10px)}
|
||||
66% {transform: translateY(-10px)}
|
||||
100% {transform: translateY(0)}`,
|
||||
"sync"
|
||||
);
|
||||
|
||||
function Loader({
|
||||
loading = false,
|
||||
color = "#000000",
|
||||
speedMultiplier = 1,
|
||||
cssOverride = {},
|
||||
size = 15,
|
||||
margin = 2,
|
||||
...additionalprops
|
||||
}) {
|
||||
const wrapper = {
|
||||
display: "inherit",
|
||||
...cssOverride,
|
||||
};
|
||||
|
||||
const style = (i) => {
|
||||
return {
|
||||
backgroundColor: color,
|
||||
width: size,
|
||||
height: size,
|
||||
margin: margin,
|
||||
borderRadius: "100%",
|
||||
display: "inline-block",
|
||||
animation: `${sync} ${0.6 / speedMultiplier}s ${i * 0.07}s infinite ease-in-out`,
|
||||
animationFillMode: "both",
|
||||
};
|
||||
};
|
||||
|
||||
if (!loading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div id="page-loader">
|
||||
<span style={wrapper} {...additionalprops}>
|
||||
<span style={style(1)}/>
|
||||
<span style={style(2)}/>
|
||||
<span style={style(3)}/>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Loader;
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
body {
|
||||
margin: 0;
|
||||
font-size: 16pt;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
|
||||
#root {
|
||||
background: #fff;
|
||||
min-height: 100vh;
|
||||
font-size: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
input {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
||||
}
|
||||
@@ -0,0 +1,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
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
@import "./variables";
|
||||
|
||||
@keyframes sb_slide_negative_left_to_right {
|
||||
from {
|
||||
transform: translateX(-$sidebar_width_1920);
|
||||
@media(max-width: 1920px) {
|
||||
transform: translateX(-$sidebar_width_1024_1920);
|
||||
}
|
||||
@media(max-width: 1024px) {
|
||||
transform: translateX(-$sidebar_width_768_1024);
|
||||
}
|
||||
@media(max-width: 768px) {
|
||||
transform: translateX(-$sidebar_width_480_768);
|
||||
}
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@mixin sb_slide_negative_left_to_right() {
|
||||
transition: 0.6s;
|
||||
animation: ease-out;
|
||||
animation-name: sb_slide_negative_left_to_right;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
|
||||
@keyframes sb_slide_right_to_negative_left {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateX(-$sidebar_width_1920);
|
||||
@media(max-width: 1920px) {
|
||||
transform: translateX(-$sidebar_width_1024_1920);
|
||||
}
|
||||
@media(max-width: 1024px) {
|
||||
transform: translateX(-$sidebar_width_768_1024);
|
||||
}
|
||||
@media(max-width: 768px) {
|
||||
transform: translateX(-$sidebar_width_480_768);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@mixin sb_slide_right_to_negative_left() {
|
||||
transition: 0.6s;
|
||||
animation: ease-out;
|
||||
animation-name: sb_slide_right_to_negative_left;
|
||||
|
||||
transform: translateX(-1 * $sidebar_width_1920);
|
||||
@media(max-width: 1920px) {
|
||||
transform: translateX(-1 * $sidebar_width_1024_1920);
|
||||
}
|
||||
@media(max-width: 1024px) {
|
||||
transform: translateX(-1 * $sidebar_width_768_1024);
|
||||
}
|
||||
@media(max-width: 768px) {
|
||||
transform: translateX(-1 * $sidebar_width_480_768);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sb_slide_left_to_right {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateX($sidebar_width_1920);
|
||||
@media(max-width: 1920px) {
|
||||
transform: translateX($sidebar_width_1024_1920);
|
||||
}
|
||||
@media(max-width: 1024px) {
|
||||
transform: translateX($sidebar_width_768_1024);
|
||||
}
|
||||
@media(max-width: 768px) {
|
||||
transform: translateX($sidebar_width_480_768);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@mixin sb_slide_left_to_right() {
|
||||
transition: 0.6s;
|
||||
animation: ease-out;
|
||||
animation-name: sb_slide_left_to_right;
|
||||
|
||||
transform: translateX($sidebar_width_1920);
|
||||
@media(max-width: 1920px) {
|
||||
transform: translateX($sidebar_width_1024_1920);
|
||||
}
|
||||
@media(max-width: 1024px) {
|
||||
transform: translateX($sidebar_width_768_1024);
|
||||
}
|
||||
@media(max-width: 768px) {
|
||||
transform: translateX($sidebar_width_480_768);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@keyframes sb_slide_right_to_left {
|
||||
from {
|
||||
transform: translateX($sidebar_width_1920);
|
||||
@media(max-width: 1920px) {
|
||||
transform: translateX($sidebar_width_1024_1920);
|
||||
}
|
||||
@media(max-width: 1024px) {
|
||||
transform: translateX($sidebar_width_768_1024);
|
||||
}
|
||||
@media(max-width: 768px) {
|
||||
transform: translateX($sidebar_width_480_768);
|
||||
}
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@mixin sb_slide_right_to_left() {
|
||||
transition: 0.6s;
|
||||
animation: ease-out;
|
||||
animation-name: sb_slide_right_to_left;
|
||||
|
||||
transform: translateX(0);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import {Photo} from "./photo.js";
|
||||
|
||||
export class Album {
|
||||
/**
|
||||
* @param {Album} [album]
|
||||
*/
|
||||
constructor(album) {
|
||||
this._id = album?._id;
|
||||
this.albumId = album?.albumId;
|
||||
this.name = album?.name;
|
||||
this.description = album?.description;
|
||||
this.sort = album?.sort;
|
||||
this.sortOrder = album?.sortOrder;
|
||||
this.photos = album?.photos?.map(photo => new Photo(photo)) || [];
|
||||
this.coverPhoto = this.photos.length > 0 ? this.photos[0] : null;
|
||||
}
|
||||
|
||||
/** @type {string} **/
|
||||
id;
|
||||
/** @type {string} **/
|
||||
albumId;
|
||||
/** @type {string} **/
|
||||
name;
|
||||
/** @type {string} **/
|
||||
description;
|
||||
/** @type {"manual"|"auto"} **/
|
||||
sort;
|
||||
/** @type {number} **/
|
||||
sortOrder;
|
||||
/** @type {Photo[]} **/
|
||||
photos;
|
||||
/** @type {Photo} **/
|
||||
coverPhoto;
|
||||
}
|
||||
@@ -0,0 +1,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) || [];
|
||||
}
|
||||
}
|
||||
@@ -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,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;
|
||||
@@ -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>
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function NoAccessPage() {
|
||||
return <h1>No access!</h1>
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
export const createAnimation = (loaderName, frames, suffix) => {
|
||||
const animationName = `react-spinners-${loaderName}-${suffix}`;
|
||||
|
||||
if (typeof window == "undefined" || !window.document) {
|
||||
return animationName;
|
||||
}
|
||||
|
||||
const styleEl = document.createElement("style");
|
||||
document.head.appendChild(styleEl);
|
||||
const styleSheet = styleEl.sheet;
|
||||
|
||||
const keyFrames = `
|
||||
@keyframes ${animationName} {
|
||||
${frames}
|
||||
}
|
||||
`;
|
||||
|
||||
if (styleSheet) {
|
||||
styleSheet.insertRule(keyFrames, 0);
|
||||
}
|
||||
|
||||
return animationName;
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
$headerSize: 60px;
|
||||
$pageMargin: 15px;
|
||||
$headerOffset: 25px;
|
||||
$borderColor: #999;
|
||||
$borderRadius: 30px;
|
||||
$backgroundColor: #fff;
|
||||
$accentColor: #de5200;
|
||||
|
||||
$sidebar_mobile_breakpoint: 768px;
|
||||
|
||||
$sidebar_mobile_height: 30vh;
|
||||
|
||||
$sidebar_width_1920: 15vw;
|
||||
$sidebar_width_1024_1920: 25vw;
|
||||
$sidebar_width_768_1024: 35vw;
|
||||
$sidebar_width_480_768: 50vw;
|
||||
Reference in New Issue
Block a user