Initial commit
This commit is contained in:
Executable
+86
@@ -0,0 +1,86 @@
|
|||||||
|
kind: pipeline
|
||||||
|
type: docker
|
||||||
|
name: build_publish
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: photos-client
|
||||||
|
image: node:21.2.0
|
||||||
|
commands:
|
||||||
|
- cd client
|
||||||
|
- npm ci
|
||||||
|
- npm run build
|
||||||
|
- echo "latest,${DRONE_BUILD_NUMBER}" > .tags
|
||||||
|
|
||||||
|
- name: photos-admin-client
|
||||||
|
image: node:21.2.0
|
||||||
|
commands:
|
||||||
|
- cd admin-client
|
||||||
|
- npm ci
|
||||||
|
- npm run build
|
||||||
|
- echo "latest,${DRONE_BUILD_NUMBER}" > .tags
|
||||||
|
|
||||||
|
- name: server
|
||||||
|
image: node:21.2.0
|
||||||
|
commands:
|
||||||
|
- cd server
|
||||||
|
- npm ci
|
||||||
|
- npm run build
|
||||||
|
- echo "latest,${DRONE_BUILD_NUMBER}" > .tags
|
||||||
|
|
||||||
|
- name: publish-api
|
||||||
|
image: plugins/docker
|
||||||
|
settings:
|
||||||
|
username:
|
||||||
|
from_secret: docker_username
|
||||||
|
password:
|
||||||
|
from_secret: docker_password
|
||||||
|
repo: registry-api.novox.be/novox/photos-server
|
||||||
|
registry: registry-api.novox.be
|
||||||
|
context: ./server
|
||||||
|
dockerfile: ./server/Dockerfile
|
||||||
|
depends_on:
|
||||||
|
- server
|
||||||
|
|
||||||
|
- name: publish-photos-client
|
||||||
|
image: plugins/docker
|
||||||
|
settings:
|
||||||
|
username:
|
||||||
|
from_secret: docker_username
|
||||||
|
password:
|
||||||
|
from_secret: docker_password
|
||||||
|
repo: registry-api.novox.be/novox/photos-client
|
||||||
|
registry: registry-api.novox.be
|
||||||
|
context: ./client
|
||||||
|
dockerfile: ./client/Dockerfile
|
||||||
|
depends_on:
|
||||||
|
- photos-client
|
||||||
|
|
||||||
|
- name: publish-photos-admin-client
|
||||||
|
image: plugins/docker
|
||||||
|
settings:
|
||||||
|
username:
|
||||||
|
from_secret: docker_username
|
||||||
|
password:
|
||||||
|
from_secret: docker_password
|
||||||
|
repo: registry-api.novox.be/novox/photos-admin-client
|
||||||
|
registry: registry-api.novox.be
|
||||||
|
context: ./admin-client
|
||||||
|
dockerfile: ./admin-client/Dockerfile
|
||||||
|
depends_on:
|
||||||
|
- photos-admin-client
|
||||||
|
|
||||||
|
trigger:
|
||||||
|
branch:
|
||||||
|
- master
|
||||||
|
|
||||||
|
---
|
||||||
|
kind: pipeline
|
||||||
|
type: exec
|
||||||
|
name: deploy
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: deploy
|
||||||
|
commands:
|
||||||
|
- systemctl restart dc@photos.novox.be
|
||||||
|
depends_on:
|
||||||
|
- build_publish
|
||||||
Executable
+2
@@ -0,0 +1,2 @@
|
|||||||
|
.idea
|
||||||
|
node_modules
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Novox Photos manager
|
||||||
|
|
||||||
|
## Client - Photos react app
|
||||||
|
|
||||||
|
### App structure
|
||||||
|
|
||||||
|
- React application with a public facing website (`./public`) and an administration panel (`./private`)
|
||||||
|
- React router is split into `<PublicRoutes />` and `<PrivateRoutes />`
|
||||||
|
- `<PrivateRoutes />` is wrapped in the `<Keycloak>` context provider
|
||||||
|
- The `<Keyclock>` context provider is created using the `AuthProvider` from the
|
||||||
|
[`oidc-react`](https://github.com/bjerkio/oidc-react) package
|
||||||
|
- AutoSignin is explicitly set to `false` to prevent the website from always trying to log in (even if not trying
|
||||||
|
to access the admin section)
|
||||||
|
- The `<AdminLayout>` component handles the authentication only rendering the `<Outlet>` when the user is logged in
|
||||||
|
and has sufficient permissions (role based)
|
||||||
|
- We can access the user info using the `useAuth` hook, only possible in React components or hooks
|
||||||
|
- Our `privateApi` service is provided using the `usePrivateApi` hook, it makes use of the `useAuth` hook to set
|
||||||
|
the authorization header
|
||||||
|
- The url for the private api is set using a "environment variable" (see build process)
|
||||||
|
|
||||||
|
### Build process
|
||||||
|
|
||||||
|
- The build process is quite simple and uses `react-scripts build`
|
||||||
|
- "Environment variables" can be set in the `.env` file. Before starting the application run `./scripts/env.sh` to
|
||||||
|
create an `env-config.js` file in the`./public` folder which is then loaded using a `<script>` tag in index.html
|
||||||
|
- In our docker setup, we can pass the environment variables in our docker-compose. When starting the container
|
||||||
|
the `env.sh` script is executed before starting the application
|
||||||
|
|
||||||
|
## Server - Photos node api
|
||||||
|
|
||||||
|
### App structure
|
||||||
|
|
||||||
|
- We use [Koa](https://github.com/koajs/koa) to setup our web api
|
||||||
|
- Our api is split into a `/api/public` and `/api/private` routes (see the 2 routers)
|
||||||
|
- `Router` and `HttpEndpoint` types can be used to add new endpoints
|
||||||
|
- Following middleware is provided:
|
||||||
|
- JWT token validation
|
||||||
|
- Request/response logging
|
||||||
|
- CORS
|
||||||
|
- Error handling
|
||||||
|
- `@koa/multer` to handle file uploads using `multipart/form-data`
|
||||||
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;
|
||||||
Executable
+1
@@ -0,0 +1 @@
|
|||||||
|
REACT_APP_API_URL=http://localhost:9001
|
||||||
Executable
+27
@@ -0,0 +1,27 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Temporary env files
|
||||||
|
/public/env-config.js
|
||||||
|
env-config.js
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
FROM nginx:1.15.2-alpine
|
||||||
|
|
||||||
|
# Nginx config
|
||||||
|
RUN rm -rf /etc/nginx/conf.d
|
||||||
|
COPY ./conf /etc/nginx
|
||||||
|
|
||||||
|
# Static build
|
||||||
|
COPY ./build /usr/share/nginx/html/
|
||||||
|
|
||||||
|
# Default port exposure
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
# Copy .env file and shell script to container
|
||||||
|
WORKDIR /usr/share/nginx/html
|
||||||
|
|
||||||
|
COPY scripts/env.sh .
|
||||||
|
COPY .env .
|
||||||
|
|
||||||
|
# Add bash
|
||||||
|
RUN apk add --no-cache bash
|
||||||
|
|
||||||
|
# Make our shell script executable
|
||||||
|
RUN chmod +x env.sh
|
||||||
|
|
||||||
|
# Start Nginx server
|
||||||
|
CMD ["/bin/bash", "-c", "/usr/share/nginx/html/env.sh && nginx -g \"daemon off;\""]
|
||||||
Executable
+13
@@ -0,0 +1,13 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
location / {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html index.htm;
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
expires -1; # Set it to different value depending on your standard requirements
|
||||||
|
}
|
||||||
|
error_page 500 502 503 504 /50x.html;
|
||||||
|
location = /50x.html {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
gzip on;
|
||||||
|
gzip_http_version 1.0;
|
||||||
|
gzip_comp_level 5; # 1-9
|
||||||
|
gzip_min_length 256;
|
||||||
|
gzip_proxied any;
|
||||||
|
gzip_vary on;
|
||||||
|
|
||||||
|
# MIME-types
|
||||||
|
gzip_types
|
||||||
|
application/atom+xml
|
||||||
|
application/javascript
|
||||||
|
application/json
|
||||||
|
application/rss+xml
|
||||||
|
application/vnd.ms-fontobject
|
||||||
|
application/x-font-ttf
|
||||||
|
application/x-web-app-manifest+json
|
||||||
|
application/xhtml+xml
|
||||||
|
application/xml
|
||||||
|
font/opentype
|
||||||
|
image/svg+xml
|
||||||
|
image/x-icon
|
||||||
|
text/css
|
||||||
|
text/plain
|
||||||
|
text/x-component;
|
||||||
Generated
+17873
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"name": "public-app",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emotion/react": "^11.11.1",
|
||||||
|
"@emotion/styled": "^11.11.0",
|
||||||
|
"@fontsource/roboto": "^5.0.8",
|
||||||
|
"@mui/icons-material": "^5.14.19",
|
||||||
|
"@mui/material": "^5.14.19",
|
||||||
|
"keycloak-js": "^23.0.0",
|
||||||
|
"oidc-react": "^3.2.2",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-router-dom": "^6.20.0",
|
||||||
|
"react-scripts": "5.0.1",
|
||||||
|
"sass": "^1.69.5"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "npm run start",
|
||||||
|
"start": "scripts/env.sh ./public/env-config.js && PORT=3006 react-scripts start",
|
||||||
|
"build": "react-scripts build",
|
||||||
|
"eject": "react-scripts eject",
|
||||||
|
"docker-build": "./scripts/docker-build.sh",
|
||||||
|
"docker-run": "./scripts/docker-run.sh"
|
||||||
|
},
|
||||||
|
"eslintConfig": {
|
||||||
|
"extends": [
|
||||||
|
"react-app"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"browserslist": {
|
||||||
|
"production": [
|
||||||
|
">0.2%",
|
||||||
|
"not dead",
|
||||||
|
"not op_mini all"
|
||||||
|
],
|
||||||
|
"development": [
|
||||||
|
"last 1 chrome version",
|
||||||
|
"last 1 firefox version",
|
||||||
|
"last 1 safari version"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/plugin-proposal-private-property-in-object": "^7.21.11"
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 7.6 KiB |
@@ -0,0 +1,45 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<script src="%PUBLIC_URL%/env-config.js"></script>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="theme-color" content="#000000" />
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Web site created using create-react-app"
|
||||||
|
/>
|
||||||
|
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||||
|
<!--
|
||||||
|
manifest.json provides metadata used when your web app is installed on a
|
||||||
|
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||||
|
-->
|
||||||
|
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||||
|
<!--
|
||||||
|
Notice the use of %PUBLIC_URL% in the tags above.
|
||||||
|
It will be replaced with the URL of the `public` folder during the build.
|
||||||
|
Only files inside the `public` folder can be referenced from the HTML.
|
||||||
|
|
||||||
|
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||||
|
work correctly both with client-side routing and a non-root public URL.
|
||||||
|
Learn how to configure a non-root public URL by running `npm run build`.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<title>Photos</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
|
<div id="root"></div>
|
||||||
|
<!--
|
||||||
|
This HTML file is a template.
|
||||||
|
If you open it directly in the browser, you will see an empty page.
|
||||||
|
|
||||||
|
You can add webfonts, meta tags, or analytics to this file.
|
||||||
|
The build step will place the bundled scripts into the <body> tag.
|
||||||
|
|
||||||
|
To begin the development, run `npm start` or `yarn start`.
|
||||||
|
To create a production bundle, use `npm run build` or `yarn build`.
|
||||||
|
-->
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"short_name": "React App",
|
||||||
|
"name": "Create React App Sample",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "favicon.ico",
|
||||||
|
"sizes": "64x64 32x32 24x24 16x16",
|
||||||
|
"type": "image/x-icon"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "logo192.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "192x192"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "logo512.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "512x512"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"start_url": ".",
|
||||||
|
"display": "standalone",
|
||||||
|
"theme_color": "#000000",
|
||||||
|
"background_color": "#ffffff"
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# https://www.robotstxt.org/robotstxt.html
|
||||||
|
User-agent: *
|
||||||
|
Disallow:
|
||||||
Executable
+1
@@ -0,0 +1 @@
|
|||||||
|
docker build . -t photos-client
|
||||||
Executable
+9
@@ -0,0 +1,9 @@
|
|||||||
|
#/bin/bash
|
||||||
|
|
||||||
|
image_name=photos-client
|
||||||
|
container_name=photos-client
|
||||||
|
|
||||||
|
docker stop $container_name
|
||||||
|
docker rm $container_name
|
||||||
|
|
||||||
|
docker run -d -p 3000:80 -e REACT_APP_API_URL=https://photos-api.novox.be --name $container_name $image_name
|
||||||
Executable
+32
@@ -0,0 +1,32 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
envConfigFile=${1:-./env-config.js}
|
||||||
|
|
||||||
|
|
||||||
|
# Recreate config file
|
||||||
|
rm -rf $envConfigFile
|
||||||
|
touch $envConfigFile
|
||||||
|
|
||||||
|
# Add assignment
|
||||||
|
echo "window._env_ = {" >> $envConfigFile
|
||||||
|
|
||||||
|
# Read each line in .env file
|
||||||
|
# Each line represents key=value pairs
|
||||||
|
while read -r line || [[ -n "$line" ]];
|
||||||
|
do
|
||||||
|
# Split env variables by character `=`
|
||||||
|
if printf '%s\n' "$line" | grep -q -e '='; then
|
||||||
|
varname=$(printf '%s\n' "$line" | sed -e 's/=.*//')
|
||||||
|
varvalue=$(printf '%s\n' "$line" | sed -e 's/^[^=]*=//')
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Read value of current variable if exists as Environment variable
|
||||||
|
value=$(printf '%s\n' "${!varname}")
|
||||||
|
# Otherwise use value from .env file
|
||||||
|
[[ -z $value ]] && value=${varvalue}
|
||||||
|
|
||||||
|
# Append configuration property to JS file
|
||||||
|
echo " $varname: \"$value\"," >> $envConfigFile
|
||||||
|
done < .env
|
||||||
|
|
||||||
|
echo "}" >> $envConfigFile
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import {createAnimation} from "../utils/animation";
|
||||||
|
|
||||||
|
const sync = createAnimation(
|
||||||
|
"SyncLoader",
|
||||||
|
`33% {transform: translateY(10px)}
|
||||||
|
66% {transform: translateY(-10px)}
|
||||||
|
100% {transform: translateY(0)}`,
|
||||||
|
"sync"
|
||||||
|
);
|
||||||
|
|
||||||
|
function Loader({
|
||||||
|
loading = false,
|
||||||
|
color = "#000000",
|
||||||
|
speedMultiplier = 1,
|
||||||
|
cssOverride = {},
|
||||||
|
size = 15,
|
||||||
|
margin = 2,
|
||||||
|
...additionalprops
|
||||||
|
}) {
|
||||||
|
const wrapper = {
|
||||||
|
display: "inherit",
|
||||||
|
...cssOverride,
|
||||||
|
};
|
||||||
|
|
||||||
|
const style = (i) => {
|
||||||
|
return {
|
||||||
|
backgroundColor: color,
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
margin: margin,
|
||||||
|
borderRadius: "100%",
|
||||||
|
display: "inline-block",
|
||||||
|
animation: `${sync} ${0.6 / speedMultiplier}s ${i * 0.07}s infinite ease-in-out`,
|
||||||
|
animationFillMode: "both",
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!loading) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div id="page-loader">
|
||||||
|
<span style={wrapper} {...additionalprops}>
|
||||||
|
<span style={style(1)}/>
|
||||||
|
<span style={style(2)}/>
|
||||||
|
<span style={style(3)}/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Loader;
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import {createRef, useEffect, useState} from "react";
|
||||||
|
|
||||||
|
import "./photo-viewer.scss"
|
||||||
|
import {ArrowLeft, ArrowRight} from "@mui/icons-material";
|
||||||
|
import {useCurrentInstance} from "../../services/current-instance/use-current-instance";
|
||||||
|
|
||||||
|
export function PhotoViewer() {
|
||||||
|
const {selectedAlbum, selectedPhotoIndex, setSelectedPhotoIndex} = useCurrentInstance();
|
||||||
|
const [touchStartX, setTouchStartX] = useState();
|
||||||
|
const [offset, setOffset] = useState(0);
|
||||||
|
const elPhotoViewer = createRef();
|
||||||
|
|
||||||
|
const hasNext = selectedPhotoIndex < (selectedAlbum?.photos.length || 0) - 1;
|
||||||
|
const hasPrevious = selectedPhotoIndex > 0;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (elPhotoViewer.current) {
|
||||||
|
elPhotoViewer.current.focus();
|
||||||
|
}
|
||||||
|
}, [elPhotoViewer]);
|
||||||
|
|
||||||
|
const handleClick = (e) => {
|
||||||
|
if (e.target === elPhotoViewer.current) {
|
||||||
|
setSelectedPhotoIndex(-1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleGoPrevious = () => {
|
||||||
|
if (hasPrevious) {
|
||||||
|
setSelectedPhotoIndex(selectedPhotoIndex - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const handleGoNext = () => {
|
||||||
|
if (hasNext) {
|
||||||
|
setSelectedPhotoIndex(selectedPhotoIndex + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const handleKeyUp = (e) => {
|
||||||
|
switch (e.code) {
|
||||||
|
case "ArrowLeft":
|
||||||
|
handleGoPrevious();
|
||||||
|
break;
|
||||||
|
case "ArrowRight":
|
||||||
|
handleGoNext();
|
||||||
|
break;
|
||||||
|
case "Escape":
|
||||||
|
setSelectedPhotoIndex(-1);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const handleTouchStart = (/** TouchEvent **/e) => {
|
||||||
|
if (e.touches.length === 1) {
|
||||||
|
//just one finger touched
|
||||||
|
setTouchStartX(e.touches.item(0).clientX);
|
||||||
|
} else {
|
||||||
|
//a second finger hit the screen, abort the touch
|
||||||
|
setTouchStartX(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTouchMove = (/** TouchEvent **/e) => {
|
||||||
|
if (e.touches.length === 1) {
|
||||||
|
const end = e.changedTouches.item(0).clientX;
|
||||||
|
setOffset(end - touchStartX);
|
||||||
|
} else {
|
||||||
|
setTouchStartX(null);
|
||||||
|
setOffset(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const handleTouchEnd = (/** TouchEvent **/e) => {
|
||||||
|
const minOffset = 100; //at least 100px are a swipe
|
||||||
|
if (touchStartX) {
|
||||||
|
//the only finger that hit the screen left it
|
||||||
|
const end = e.changedTouches.item(0).clientX;
|
||||||
|
if (end > touchStartX + minOffset) {
|
||||||
|
//a left -> right swipe
|
||||||
|
handleGoPrevious();
|
||||||
|
} else if (end < touchStartX - minOffset) {
|
||||||
|
//a right -> left swipe
|
||||||
|
handleGoNext();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setOffset(0);
|
||||||
|
}
|
||||||
|
const previousPhotoStyle = {
|
||||||
|
right: `calc(100% + ${15 - offset}px)`
|
||||||
|
}
|
||||||
|
const nextPhotoStyle = {
|
||||||
|
left: `calc(100% - ${-offset}px)`
|
||||||
|
}
|
||||||
|
const photoStyle = {
|
||||||
|
marginLeft: `${offset}px`
|
||||||
|
}
|
||||||
|
return <>
|
||||||
|
{selectedPhotoIndex >= 0 ?
|
||||||
|
<div tabIndex={1} ref={elPhotoViewer} id="photoViewer" onClick={handleClick} onKeyUp={handleKeyUp}
|
||||||
|
onTouchStart={handleTouchStart} onTouchEnd={handleTouchEnd} onTouchMove={handleTouchMove}>
|
||||||
|
<div className="body-overlay"></div>
|
||||||
|
<div className="goto-btn goto-btn--previous" onClick={handleGoPrevious}>
|
||||||
|
{hasPrevious && <ArrowLeft/>}
|
||||||
|
</div>
|
||||||
|
<div id="photoSlider">
|
||||||
|
{hasPrevious && <div className="photo photo--placeholder photo--previous" style={previousPhotoStyle}>
|
||||||
|
<img src={`${selectedAlbum.photos[selectedPhotoIndex - 1].url}`}
|
||||||
|
alt={selectedAlbum.photos[selectedPhotoIndex - 1].name}/>
|
||||||
|
</div>}
|
||||||
|
<div className="photo photo--invisible">
|
||||||
|
<img src={`${selectedAlbum.photos[selectedPhotoIndex].url}`}
|
||||||
|
alt={selectedAlbum.photos[selectedPhotoIndex].name}/>
|
||||||
|
</div>
|
||||||
|
<div className="photo photo--placeholder" style={photoStyle}>
|
||||||
|
<img src={`${selectedAlbum.photos[selectedPhotoIndex].url}`}
|
||||||
|
alt={selectedAlbum.photos[selectedPhotoIndex].name}/>
|
||||||
|
</div>
|
||||||
|
{hasNext && <div className="photo photo--placeholder photo--next" style={nextPhotoStyle}>
|
||||||
|
<img src={`${selectedAlbum.photos[selectedPhotoIndex + 1].url}`}
|
||||||
|
alt={selectedAlbum.photos[selectedPhotoIndex + 1].name}/>
|
||||||
|
</div>}
|
||||||
|
</div>
|
||||||
|
<div className="goto-btn goto-btn--next" onClick={handleGoNext}>
|
||||||
|
{hasNext && <ArrowRight/>}
|
||||||
|
</div>
|
||||||
|
</div> : null}
|
||||||
|
</>
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
|
||||||
|
#photoViewer {
|
||||||
|
z-index: 999;
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
background: rgba(0, 0, 0, 0.95);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: stretch;
|
||||||
|
|
||||||
|
#photoSlider {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 1;
|
||||||
|
max-height: 90vh;
|
||||||
|
|
||||||
|
.photo {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
img {
|
||||||
|
margin: 0 auto;
|
||||||
|
max-height: 90vh;
|
||||||
|
max-width: calc(100vw - 300px);
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
max-width: calc(100vw - 150px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.photo--placeholder {
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.photo--invisible {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.goto-btn {
|
||||||
|
cursor: pointer;
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
svg {
|
||||||
|
transform: scale(1.2);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
color: white;
|
||||||
|
opacity: 0.15;
|
||||||
|
width: 75px;
|
||||||
|
height: 75px;
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
width: 75px;
|
||||||
|
height: 75px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.goto-btn--previous {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.goto-btn--next {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import {useEffect} from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook that alerts clicks outside of the passed ref
|
||||||
|
*/
|
||||||
|
export function useOutsideAlerter(ref, handler) {
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClickOutside(event) {
|
||||||
|
if (ref.current && !ref.current.contains(event.target)) {
|
||||||
|
handler(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Bind the event listener
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
return () => {
|
||||||
|
// Unbind the event listener on clean up
|
||||||
|
document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
};
|
||||||
|
}, [ref, handler]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16pt;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||||
|
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||||
|
sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||||
|
monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
background: #fff;
|
||||||
|
min-height: 100vh;
|
||||||
|
font-size: 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import React, {useLayoutEffect} from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import './index.css';
|
||||||
|
import {BrowserRouter, Navigate, Route, Routes, useLocation} from "react-router-dom";
|
||||||
|
|
||||||
|
import '@fontsource/roboto/300.css';
|
||||||
|
import '@fontsource/roboto/400.css';
|
||||||
|
import '@fontsource/roboto/500.css';
|
||||||
|
import '@fontsource/roboto/700.css';
|
||||||
|
import PublicLayout from "./public/layout";
|
||||||
|
import Portfolio from "./public/pages/portfolio";
|
||||||
|
import AlbumPage from "./public/pages/album";
|
||||||
|
import AccessDenied from "./public/pages/access-denied";
|
||||||
|
import {HomePage} from "./public/pages/home-page";
|
||||||
|
import {CurrentInstanceProvider} from "./services/current-instance/current-instance-context";
|
||||||
|
|
||||||
|
|
||||||
|
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||||
|
root.render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<CurrentInstanceProvider>
|
||||||
|
<ScrollToTop>
|
||||||
|
<Routes>
|
||||||
|
<Route index element={<HomePage/>}/>
|
||||||
|
<Route path="/portfolio" element={<PublicLayout/>}>
|
||||||
|
<Route index element={<Portfolio/>}/>
|
||||||
|
<Route path=":albumId" element={<AlbumPage/>}/>
|
||||||
|
<Route path="401" element={<AccessDenied/>}/>
|
||||||
|
<Route path="*" element={<Navigate to="/"/>}/>
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</ScrollToTop>
|
||||||
|
</CurrentInstanceProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
|
|
||||||
|
function ScrollToTop({children}) {
|
||||||
|
const location = useLocation();
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
document.documentElement.scrollTo(0, 0);
|
||||||
|
}, [location.pathname]);
|
||||||
|
return children
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
export function Logo() {
|
||||||
|
|
||||||
|
return <svg width="50px" height="40px" fill="currentcolor" viewBox="0 0 49 40">
|
||||||
|
<g id="surface1">
|
||||||
|
<path d="M 15.554688 0.195312 C 15.027344 0.40625 14.472656 0.863281 14.191406 1.3125 C 14.019531 1.585938 13.792969 2.351562 13.792969 2.640625 C 13.792969 2.695312 17.792969 2.730469 24.507812 2.730469 L 35.222656 2.730469 L 35.222656 2.542969 C 35.222656 2.210938 35.023438 1.539062 34.824219 1.222656 C 34.554688 0.785156 34.078125 0.40625 33.515625 0.183594 C 33.046875 0 32.96875 0 24.527344 0.0078125 C 16.144531 0.0078125 15.996094 0.0195312 15.554688 0.195312 Z M 15.554688 0.195312 "/>
|
||||||
|
<path d="M 7.289062 3.707031 C 4.960938 3.75 4.683594 3.769531 4.25 3.933594 C 3.390625 4.269531 3.113281 4.402344 2.664062 4.726562 C 1.441406 5.582031 0.695312 6.65625 0.261719 8.160156 C 0.113281 8.679688 0.09375 9.445312 0.0859375 21.761719 L 0.0703125 34.816406 L 0.261719 35.429688 C 0.605469 36.539062 1.015625 37.210938 1.867188 38.070312 C 2.6875 38.917969 3.226562 39.261719 4.363281 39.683594 L 4.988281 39.910156 L 9.542969 39.945312 C 12.050781 39.957031 14.367188 39.9375 14.6875 39.902344 L 15.277344 39.832031 L 14.601562 39.339844 C 13.371094 38.433594 12.023438 37.085938 11.253906 35.976562 L 10.898438 35.476562 L 6.019531 35.476562 L 5.578125 35.253906 C 5.101562 35.007812 4.773438 34.640625 4.617188 34.164062 C 4.46875 33.730469 4.46875 9.894531 4.617188 9.410156 C 4.738281 8.980469 5.195312 8.433594 5.585938 8.238281 C 5.855469 8.105469 7.46875 8.097656 24.5 8.097656 C 39.996094 8.097656 43.171875 8.117188 43.402344 8.222656 C 43.828125 8.398438 44.210938 8.792969 44.417969 9.269531 L 44.601562 9.691406 L 44.539062 21.902344 C 44.488281 33.496094 44.472656 34.128906 44.324219 34.4375 C 44.125 34.839844 43.605469 35.28125 43.1875 35.394531 C 42.96875 35.449219 39.371094 35.476562 32.820312 35.457031 C 23.503906 35.429688 22.738281 35.421875 22.339844 35.273438 C 22.105469 35.195312 21.65625 35.035156 21.34375 34.929688 C 20.066406 34.507812 18.773438 33.660156 17.664062 32.535156 C 16.996094 31.855469 16.136719 30.757812 16.136719 30.582031 C 16.136719 30.554688 16.066406 30.421875 15.972656 30.300781 C 15.746094 29.964844 15.320312 28.847656 15.085938 27.984375 C 14.921875 27.339844 14.894531 27.042969 14.894531 25.75 C 14.894531 24.628906 14.9375 24.136719 15.042969 23.8125 C 15.121094 23.566406 15.183594 23.300781 15.183594 23.214844 C 15.183594 22.957031 15.789062 21.46875 16.164062 20.820312 C 17.757812 18.011719 20.371094 16.285156 23.570312 15.933594 C 26.972656 15.554688 30.652344 17.296875 32.542969 20.195312 C 33.097656 21.046875 33.660156 22.296875 33.886719 23.167969 C 34.070312 23.882812 34.09375 24.171875 34.09375 25.597656 C 34.09375 27.492188 33.949219 28.34375 33.390625 29.570312 C 33.019531 30.371094 32.28125 31.550781 31.734375 32.175781 L 31.386719 32.570312 L 34.136719 32.554688 L 36.878906 32.527344 L 37.046875 32.21875 C 37.816406 30.765625 38.363281 28.792969 38.527344 26.867188 C 38.804688 23.75 38.058594 20.652344 36.402344 18 C 35.007812 15.757812 33.160156 14.074219 30.753906 12.871094 C 27.753906 11.355469 23.632812 11.03125 20.386719 12.050781 C 17.332031 13 14.601562 15.054688 12.855469 17.695312 C 11.851562 19.226562 11.148438 20.949219 10.707031 22.976562 C 10.472656 24.058594 10.472656 27.351562 10.707031 28.433594 C 11.582031 32.457031 13.492188 35.386719 16.640625 37.527344 C 17.984375 38.441406 19.617188 39.144531 21.507812 39.628906 C 22.253906 39.824219 22.296875 39.824219 33.203125 39.851562 L 44.148438 39.886719 L 44.773438 39.65625 C 46.570312 39.023438 47.847656 37.800781 48.53125 36.054688 C 49.042969 34.753906 49.027344 35.441406 48.964844 21.523438 C 48.929688 13.566406 48.878906 8.625 48.828125 8.40625 C 48.226562 5.933594 46.285156 4.09375 43.855469 3.6875 C 43.414062 3.617188 38.816406 3.601562 26.546875 3.617188 C 17.359375 3.636719 8.691406 3.671875 7.289062 3.707031 Z M 7.289062 3.707031 "/>
|
||||||
|
<path d="M 6.707031 10.007812 C 6.628906 10.035156 6.59375 10.265625 6.59375 10.703125 L 6.59375 11.355469 L 13.621094 11.355469 L 13.621094 10.703125 C 13.621094 10.210938 13.585938 10.042969 13.492188 10.007812 C 13.335938 9.9375 6.855469 9.9375 6.707031 10.007812 Z M 6.707031 10.007812 "/>
|
||||||
|
<path d="M 22.902344 17.121094 C 20.65625 17.613281 18.703125 18.890625 17.523438 20.660156 C 17.132812 21.242188 17.109375 21.320312 17.28125 21.257812 C 17.394531 21.222656 18.425781 21.179688 19.582031 21.152344 C 21.246094 21.117188 21.90625 21.136719 22.789062 21.257812 C 24.039062 21.425781 25.339844 21.714844 26.242188 22.042969 C 26.574219 22.164062 26.859375 22.246094 26.878906 22.226562 C 26.988281 22.113281 25.792969 19.929688 24.570312 18.027344 C 23.824219 16.867188 23.902344 16.910156 22.902344 17.121094 Z M 22.902344 17.121094 "/>
|
||||||
|
<path d="M 24.863281 17.21875 C 26.226562 19.277344 26.929688 20.535156 27.5625 22.015625 C 28.089844 23.25 28.324219 24.03125 28.691406 25.738281 L 28.757812 26.082031 L 29.296875 25.253906 C 30.097656 24.003906 31.667969 20.96875 31.667969 20.660156 C 31.667969 20.449219 30.234375 18.96875 29.609375 18.546875 C 28.292969 17.648438 26.816406 17.09375 25.472656 17.015625 L 24.691406 16.964844 Z M 24.863281 17.21875 "/>
|
||||||
|
<path d="M 31.96875 21.671875 C 31.855469 22.035156 30.796875 24.136719 30.261719 25.0625 C 29.480469 26.390625 28.714844 27.386719 27.546875 28.582031 C 26.652344 29.507812 26.519531 29.675781 26.667969 29.71875 C 26.945312 29.804688 29.636719 29.78125 30.625 29.683594 C 31.128906 29.628906 31.753906 29.578125 32.011719 29.558594 L 32.492188 29.542969 L 32.644531 29.144531 C 33.082031 28.027344 33.175781 27.472656 33.175781 25.925781 C 33.183594 24.613281 33.160156 24.339844 32.976562 23.679688 C 32.777344 22.929688 32.394531 22 32.160156 21.644531 L 32.03125 21.460938 Z M 31.96875 21.671875 "/>
|
||||||
|
<path d="M 18.261719 22.007812 C 17.742188 22.042969 17.144531 22.078125 16.945312 22.085938 L 16.585938 22.09375 L 16.363281 22.683594 C 15.484375 25.042969 15.609375 27.570312 16.710938 29.78125 C 16.855469 30.070312 16.960938 30.183594 16.988281 30.105469 C 17.046875 29.902344 17.976562 28.027344 18.304688 27.421875 C 19.214844 25.765625 20.335938 24.277344 21.636719 23 L 22.617188 22.035156 L 22.042969 21.972656 C 21.359375 21.910156 19.570312 21.929688 18.261719 22.007812 Z M 18.261719 22.007812 "/>
|
||||||
|
<path d="M 20.292969 25.667969 C 20.128906 25.855469 19.171875 27.351562 19.171875 27.410156 C 19.171875 27.4375 19.050781 27.667969 18.902344 27.921875 C 18.757812 28.179688 18.359375 28.933594 18.027344 29.605469 C 17.308594 31.046875 17.289062 30.90625 18.347656 32.007812 C 19.796875 33.511719 21.6875 34.445312 23.546875 34.570312 L 24.40625 34.621094 L 23.972656 33.96875 C 22.566406 31.847656 21.523438 29.78125 20.996094 28.035156 C 20.832031 27.492188 20.5 26.082031 20.457031 25.714844 C 20.429688 25.511719 20.421875 25.511719 20.292969 25.667969 Z M 20.292969 25.667969 "/>
|
||||||
|
<path d="M 22.46875 29.675781 C 22.574219 29.886719 22.722656 30.203125 22.800781 30.371094 C 23.46875 31.75 25.273438 34.59375 25.488281 34.59375 C 25.878906 34.59375 26.800781 34.394531 27.492188 34.15625 C 29.332031 33.53125 30.9375 32.261719 31.933594 30.632812 L 32.074219 30.414062 L 29.332031 30.414062 C 26.21875 30.414062 25.359375 30.308594 23.511719 29.699219 C 23.292969 29.621094 23.007812 29.535156 22.859375 29.5 C 22.722656 29.453125 22.53125 29.394531 22.445312 29.355469 C 22.296875 29.296875 22.296875 29.3125 22.46875 29.675781 Z M 22.46875 29.675781 "/>
|
||||||
|
</g>
|
||||||
|
</svg>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
@import "./variables";
|
||||||
|
|
||||||
|
@keyframes sb_slide_negative_left_to_right {
|
||||||
|
from {
|
||||||
|
transform: translateX(-$sidebar_width_1920);
|
||||||
|
@media(max-width: 1920px) {
|
||||||
|
transform: translateX(-$sidebar_width_1024_1920);
|
||||||
|
}
|
||||||
|
@media(max-width: 1024px) {
|
||||||
|
transform: translateX(-$sidebar_width_768_1024);
|
||||||
|
}
|
||||||
|
@media(max-width: 768px) {
|
||||||
|
transform: translateX(-$sidebar_width_480_768);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin sb_slide_negative_left_to_right() {
|
||||||
|
transition: 0.6s;
|
||||||
|
animation: ease-out;
|
||||||
|
animation-name: sb_slide_negative_left_to_right;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@keyframes sb_slide_right_to_negative_left {
|
||||||
|
from {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: translateX(-$sidebar_width_1920);
|
||||||
|
@media(max-width: 1920px) {
|
||||||
|
transform: translateX(-$sidebar_width_1024_1920);
|
||||||
|
}
|
||||||
|
@media(max-width: 1024px) {
|
||||||
|
transform: translateX(-$sidebar_width_768_1024);
|
||||||
|
}
|
||||||
|
@media(max-width: 768px) {
|
||||||
|
transform: translateX(-$sidebar_width_480_768);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin sb_slide_right_to_negative_left() {
|
||||||
|
transition: 0.6s;
|
||||||
|
animation: ease-out;
|
||||||
|
animation-name: sb_slide_right_to_negative_left;
|
||||||
|
|
||||||
|
transform: translateX(-1 * $sidebar_width_1920);
|
||||||
|
@media(max-width: 1920px) {
|
||||||
|
transform: translateX(-1 * $sidebar_width_1024_1920);
|
||||||
|
}
|
||||||
|
@media(max-width: 1024px) {
|
||||||
|
transform: translateX(-1 * $sidebar_width_768_1024);
|
||||||
|
}
|
||||||
|
@media(max-width: 768px) {
|
||||||
|
transform: translateX(-1 * $sidebar_width_480_768);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes sb_slide_left_to_right {
|
||||||
|
from {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: translateX($sidebar_width_1920);
|
||||||
|
@media(max-width: 1920px) {
|
||||||
|
transform: translateX($sidebar_width_1024_1920);
|
||||||
|
}
|
||||||
|
@media(max-width: 1024px) {
|
||||||
|
transform: translateX($sidebar_width_768_1024);
|
||||||
|
}
|
||||||
|
@media(max-width: 768px) {
|
||||||
|
transform: translateX($sidebar_width_480_768);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin sb_slide_left_to_right() {
|
||||||
|
transition: 0.6s;
|
||||||
|
animation: ease-out;
|
||||||
|
animation-name: sb_slide_left_to_right;
|
||||||
|
|
||||||
|
transform: translateX($sidebar_width_1920);
|
||||||
|
@media(max-width: 1920px) {
|
||||||
|
transform: translateX($sidebar_width_1024_1920);
|
||||||
|
}
|
||||||
|
@media(max-width: 1024px) {
|
||||||
|
transform: translateX($sidebar_width_768_1024);
|
||||||
|
}
|
||||||
|
@media(max-width: 768px) {
|
||||||
|
transform: translateX($sidebar_width_480_768);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@keyframes sb_slide_right_to_left {
|
||||||
|
from {
|
||||||
|
transform: translateX($sidebar_width_1920);
|
||||||
|
@media(max-width: 1920px) {
|
||||||
|
transform: translateX($sidebar_width_1024_1920);
|
||||||
|
}
|
||||||
|
@media(max-width: 1024px) {
|
||||||
|
transform: translateX($sidebar_width_768_1024);
|
||||||
|
}
|
||||||
|
@media(max-width: 768px) {
|
||||||
|
transform: translateX($sidebar_width_480_768);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin sb_slide_right_to_left() {
|
||||||
|
transition: 0.6s;
|
||||||
|
animation: ease-out;
|
||||||
|
animation-name: sb_slide_right_to_left;
|
||||||
|
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import {Photo} from "./photo.js";
|
||||||
|
|
||||||
|
export class Album {
|
||||||
|
/**
|
||||||
|
* @param {Album} [album]
|
||||||
|
*/
|
||||||
|
constructor(album) {
|
||||||
|
this._id = album?._id;
|
||||||
|
this.albumId = album?.albumId;
|
||||||
|
this.name = album?.name;
|
||||||
|
this.description = album?.description;
|
||||||
|
this.sort = album?.sort;
|
||||||
|
this.sortOrder = album?.sortOrder;
|
||||||
|
this.photos = album?.photos?.map(photo => new Photo(photo)) || [];
|
||||||
|
this.coverPhoto = this.photos.length > 0 ? this.photos[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {string} **/
|
||||||
|
id;
|
||||||
|
/** @type {string} **/
|
||||||
|
albumId;
|
||||||
|
/** @type {string} **/
|
||||||
|
name;
|
||||||
|
/** @type {string} **/
|
||||||
|
description;
|
||||||
|
/** @type {"manual"|"auto"} **/
|
||||||
|
sort;
|
||||||
|
/** @type {number} **/
|
||||||
|
sortOrder;
|
||||||
|
/** @type {Photo[]} **/
|
||||||
|
photos;
|
||||||
|
/** @type {Photo} **/
|
||||||
|
coverPhoto;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import {Photo} from "./photo";
|
||||||
|
|
||||||
|
export class Instance {
|
||||||
|
constructor(instance) {
|
||||||
|
this._id = instance?._id;
|
||||||
|
this.instanceId = instance?.instanceId;
|
||||||
|
this.title = instance?.title;
|
||||||
|
this.subtitle = instance?.subtitle;
|
||||||
|
this.coverPhoto = instance?.coverPhoto ? new Photo(instance.coverPhoto) : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
export class Photo {
|
||||||
|
/**
|
||||||
|
* @param {Photo} photo
|
||||||
|
*/
|
||||||
|
constructor(photo) {
|
||||||
|
this.photoId = photo?.photoId;
|
||||||
|
this.albumId = photo?.albumId;
|
||||||
|
this.name = photo?.name;
|
||||||
|
this.fileName = photo?.fileName;
|
||||||
|
this.mimeType = photo?.mimeType;
|
||||||
|
this.s3Path = photo?.s3Path;
|
||||||
|
this.description = photo?.description;
|
||||||
|
this.sortOrder = photo?.sortOrder;
|
||||||
|
this.url = `${window._env_.REACT_APP_API_URL}/api/s3/${this.s3Path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {string} **/
|
||||||
|
photoId;
|
||||||
|
/** @type {string} **/
|
||||||
|
albumId;
|
||||||
|
/** @type {string} **/
|
||||||
|
name;
|
||||||
|
/** @type {string} **/
|
||||||
|
s3Path;
|
||||||
|
/** @type {string} **/
|
||||||
|
description;
|
||||||
|
/** @type {number} **/
|
||||||
|
sortOrder;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import {createContext} from "react";
|
||||||
|
|
||||||
|
export const PageTransitionContext = createContext({
|
||||||
|
mounted: false
|
||||||
|
})
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import "./public.scss"
|
||||||
|
import {useLayoutEffect, useState} from "react";
|
||||||
|
import {NavLink, Outlet, useLocation} from "react-router-dom";
|
||||||
|
import {KeyboardArrowRightRounded, Menu} from "@mui/icons-material";
|
||||||
|
import {PageTransitionContext} from "./context/pageTransition";
|
||||||
|
import {PhotoViewer} from "../components/photo-viewer/photo-viewer";
|
||||||
|
import Loader from "../components/Loader";
|
||||||
|
import {useCurrentInstance} from "../services/current-instance/use-current-instance";
|
||||||
|
|
||||||
|
export default function PublicLayout() {
|
||||||
|
const {isLoading, apiError, albums} = useCurrentInstance();
|
||||||
|
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
const pageTransition = {
|
||||||
|
mounted, setMounted
|
||||||
|
}
|
||||||
|
// const fetchedRef = useRef(false);
|
||||||
|
const [sidebarIsOpen, setSidebarIsOpen] = useState(false);
|
||||||
|
|
||||||
|
const location = useLocation();
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
setMounted(false);
|
||||||
|
setTimeout(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, 10);
|
||||||
|
}, [location.pathname]);
|
||||||
|
|
||||||
|
return <PageTransitionContext.Provider value={pageTransition}>
|
||||||
|
<PhotoViewer />
|
||||||
|
<div id="publicPage">
|
||||||
|
<main id="page">
|
||||||
|
<Loader loading={isLoading} />
|
||||||
|
{!isLoading && <>
|
||||||
|
<div id="sidebarButton" onClick={() => setSidebarIsOpen(!sidebarIsOpen)}>
|
||||||
|
<Menu />
|
||||||
|
</div>
|
||||||
|
<div id="sidebar" className={`${(sidebarIsOpen ? "sidebar-open" : "sidebar-closed")}`}>
|
||||||
|
<div className="sidebar">
|
||||||
|
<NavLink onClick={() => setSidebarIsOpen(false)} to="/" className="home">Home</NavLink>
|
||||||
|
{albums.map(a => <NavLink onClick={() => setSidebarIsOpen(false)} key={a.albumId} to={a.albumId}><KeyboardArrowRightRounded /> {a.name}</NavLink>)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="sidebar-mobile" className={`sidebar ${(sidebarIsOpen ? "sidebar-open" : "sidebar-closed")}`}>
|
||||||
|
<NavLink onClick={() => setSidebarIsOpen(false)} to="/" className="home">Home</NavLink>
|
||||||
|
{albums.map(a => <NavLink onClick={() => setSidebarIsOpen(false)} key={a.albumId} to={a.albumId}><KeyboardArrowRightRounded /> {a.name}</NavLink>)}
|
||||||
|
</div>
|
||||||
|
<div id={"page-content"} className={`${(sidebarIsOpen ? "sidebar-open" : "sidebar-closed")} ${(pageTransition.mounted ? "mounted" : "unmounted")}`}>
|
||||||
|
{apiError && <div className="error-box">{apiError}</div>}
|
||||||
|
{!apiError && <Outlet />}
|
||||||
|
</div>
|
||||||
|
</>}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</PageTransitionContext.Provider>
|
||||||
|
}
|
||||||
Executable
+7
@@ -0,0 +1,7 @@
|
|||||||
|
function AccessDenied() {
|
||||||
|
return (
|
||||||
|
<h1>Access denied!</h1>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AccessDenied;
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import {useEffect} from "react";
|
||||||
|
import {useParams} from "react-router-dom";
|
||||||
|
import {ZoomOutMapSharp} from "@mui/icons-material";
|
||||||
|
import {useCurrentInstance} from "../../services/current-instance/use-current-instance";
|
||||||
|
|
||||||
|
export default function AlbumPage() {
|
||||||
|
const {
|
||||||
|
albums,
|
||||||
|
selectedAlbum,
|
||||||
|
setSelectedAlbum,
|
||||||
|
setSelectedPhotoIndex
|
||||||
|
} = useCurrentInstance();
|
||||||
|
const {albumId} = useParams();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const ab = albums.filter(a => a.albumId === albumId)[0];
|
||||||
|
if (selectedAlbum !== ab) {
|
||||||
|
setSelectedAlbum(ab);
|
||||||
|
}
|
||||||
|
}, [albums, selectedAlbum, albumId, setSelectedAlbum]);
|
||||||
|
|
||||||
|
return selectedAlbum && <div id="page-album" className="page-outlet">
|
||||||
|
<div className="page-title">
|
||||||
|
<div className="title">
|
||||||
|
{selectedAlbum.name}
|
||||||
|
</div>
|
||||||
|
{selectedAlbum.description &&
|
||||||
|
<div className="extra-info"
|
||||||
|
dangerouslySetInnerHTML={{__html: selectedAlbum.description.replace(/\n/g, "<br />")}}>
|
||||||
|
</div>}
|
||||||
|
</div>
|
||||||
|
{selectedAlbum.photos && <div id="photo-list" className="photo-grid-layout">
|
||||||
|
{selectedAlbum.photos.map((photo, idx) =>
|
||||||
|
<div className="photo grid-cell" key={photo.photoId}
|
||||||
|
onClick={() => setSelectedPhotoIndex(idx)}>
|
||||||
|
<div className="thumbnail photo-thumbnail">
|
||||||
|
<img src={`${photo.url}?width=800&height=800&fit=cover`}
|
||||||
|
alt={photo.name}/>
|
||||||
|
<div className="zoom-button">
|
||||||
|
<ZoomOutMapSharp/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="details">
|
||||||
|
<div className="name">
|
||||||
|
{photo.name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import {useCurrentInstance} from "../../services/current-instance/use-current-instance";
|
||||||
|
|
||||||
|
import "./home-page.scss";
|
||||||
|
import {NavLink} from "react-router-dom";
|
||||||
|
|
||||||
|
export function HomePage() {
|
||||||
|
const {instance} = useCurrentInstance();
|
||||||
|
|
||||||
|
return instance && <div id="home">
|
||||||
|
<div className="cover-photo">
|
||||||
|
<img alt={instance.coverPhoto?.name} src={instance.coverPhoto?.url}/>
|
||||||
|
</div>
|
||||||
|
<div className="instance-details">
|
||||||
|
<div className="box">
|
||||||
|
<h1>{instance.title}</h1>
|
||||||
|
<h2>{instance.subtitle}</h2>
|
||||||
|
<NavLink to="/portfolio" className="goto-website-link">
|
||||||
|
GO TO WEBSITE
|
||||||
|
</NavLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
#home {
|
||||||
|
height: 100vh;
|
||||||
|
width: 100vw;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
|
||||||
|
@media(max-width: 1200px) {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-photo {
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-position: 50% 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.instance-details {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.box {
|
||||||
|
position: static !important;
|
||||||
|
transform: none;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
padding: 60px;
|
||||||
|
max-width: 620px;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
text-align: center;
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 37px;
|
||||||
|
line-height: 1em;
|
||||||
|
margin: 0;
|
||||||
|
margin-bottom: 22px;
|
||||||
|
//font-family: proxima-nova;
|
||||||
|
font-weight: 700;
|
||||||
|
font-style: normal;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .1em;
|
||||||
|
color: #333;
|
||||||
|
|
||||||
|
@media(max-width: 600px) {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
//font-family: minion-pro;
|
||||||
|
font-size: 19px;
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: normal;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .28em;
|
||||||
|
line-height: 1.7em;
|
||||||
|
color: #333;
|
||||||
|
margin: 0;
|
||||||
|
margin-bottom: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.goto-website-link {
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
white-space: nowrap;
|
||||||
|
line-height: 1em;
|
||||||
|
//font-family: proxima-nova;
|
||||||
|
font-weight: 500;
|
||||||
|
font-style: normal;
|
||||||
|
font-size: 14px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0;
|
||||||
|
text-align: center;
|
||||||
|
padding: 1em calc(1.44em - 0em) 1em 1.44em;
|
||||||
|
display: inline-block;
|
||||||
|
text-decoration: none;
|
||||||
|
background-color: transparent;
|
||||||
|
color: #333;
|
||||||
|
border: 2px solid #333;
|
||||||
|
transition: background-color 170ms ease-in-out, color 170ms ease-in-out;
|
||||||
|
//transition: color 170ms ease-in-out, border-color 170ms ease-in-out;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: black;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import {Link} from "react-router-dom";
|
||||||
|
import * as React from "react";
|
||||||
|
import {ImageSearch} from "@mui/icons-material";
|
||||||
|
|
||||||
|
import {useCurrentInstance} from "../../services/current-instance/use-current-instance";
|
||||||
|
|
||||||
|
function Portfolio() {
|
||||||
|
const {instance, albums} = useCurrentInstance();
|
||||||
|
|
||||||
|
return instance && <div id="page-portfolio" className="page-outlet">
|
||||||
|
<div className="page-title">
|
||||||
|
<div className="title">
|
||||||
|
{instance.title}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{albums?.length > 0 && <div id="album-list" className="photo-grid-layout">
|
||||||
|
{albums.map((album) =>
|
||||||
|
<div className="album grid-cell" key={album.albumId}>
|
||||||
|
<Link to={album.albumId} className={`thumbnail ${(album.coverPhoto ? "" : "thumbnail--no-img")}`}>
|
||||||
|
{album.coverPhoto && <img src={`${album.coverPhoto.url}?width=800&height=800&fit=cover`} alt={album.coverPhoto.name} />}
|
||||||
|
{!album.coverPhoto && <ImageSearch />}
|
||||||
|
</Link>
|
||||||
|
<div className="details">
|
||||||
|
<div className="name">
|
||||||
|
{album.name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>}
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
export default Portfolio;
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
@import "../variables.scss";
|
||||||
|
@import "../mixins.scss";
|
||||||
|
html:has(#photoViewer) {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
#publicPage {
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
padding: 15px;
|
||||||
|
|
||||||
|
#page {
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
@media(max-width: $sidebar_mobile_breakpoint) {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
#sidebarButton {
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 999;
|
||||||
|
position: relative;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
|
||||||
|
> svg {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#sidebar {
|
||||||
|
padding-left: 15px;
|
||||||
|
padding-top: 15px;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
position: fixed;
|
||||||
|
|
||||||
|
@media(max-width: $sidebar_mobile_breakpoint) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.sidebar-open {
|
||||||
|
@include sb_slide_negative_left_to_right();
|
||||||
|
}
|
||||||
|
|
||||||
|
&.sidebar-closed {
|
||||||
|
@include sb_slide_right_to_negative_left();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#sidebar-mobile {
|
||||||
|
padding-left: 50px;
|
||||||
|
background: white;
|
||||||
|
width: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: none;
|
||||||
|
flex-direction: column;
|
||||||
|
height: $sidebar_mobile_height;
|
||||||
|
border-bottom: 1px solid #000;
|
||||||
|
|
||||||
|
@media(max-width: $sidebar_mobile_breakpoint) {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideDown {
|
||||||
|
from {
|
||||||
|
margin-top: calc(-1 * $sidebar_mobile_height - 50px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.sidebar-open {
|
||||||
|
margin-top: 0;
|
||||||
|
transition: 0.5s;
|
||||||
|
animation: ease-in;
|
||||||
|
animation-name: slideDown;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
margin-top: calc(-1 * $sidebar_mobile_height - 50px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.sidebar-closed {
|
||||||
|
margin-top: calc(-1 * $sidebar_mobile_height - 50px);
|
||||||
|
transition: 0.5s;
|
||||||
|
animation: ease-in;
|
||||||
|
animation-name: slideUp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
> a {
|
||||||
|
width: 100%;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1.4em;
|
||||||
|
color: #000;
|
||||||
|
text-decoration: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
height: 19px;
|
||||||
|
width: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&.active {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:visited,
|
||||||
|
&:hover {
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#page-content {
|
||||||
|
padding-top: 20px;
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.page-outlet {
|
||||||
|
transform: translateX(100vw);
|
||||||
|
|
||||||
|
@media (max-width: $sidebar_mobile_breakpoint) {
|
||||||
|
transform: translateX(0);
|
||||||
|
margin-top: 50px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.mounted {
|
||||||
|
.page-outlet {
|
||||||
|
@include sb_slide_right_to_left();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.sidebar-closed {
|
||||||
|
@include sb_slide_right_to_left();
|
||||||
|
@media (max-width: $sidebar_mobile_breakpoint) {
|
||||||
|
animation: none;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.sidebar-open {
|
||||||
|
@include sb_slide_left_to_right();
|
||||||
|
@media (max-width: $sidebar_mobile_breakpoint) {
|
||||||
|
animation: none;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 2.4rem;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.extra-info {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
color: #333;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-grid-layout {
|
||||||
|
width: 100%;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
grid-auto-rows: 1fr;
|
||||||
|
row-gap: calc(4em + 30px);
|
||||||
|
column-gap: 30px;
|
||||||
|
margin-bottom: 4em;
|
||||||
|
|
||||||
|
//@media (max-width: 1920px) {
|
||||||
|
// grid-template-columns: repeat(4, 1fr);
|
||||||
|
//}
|
||||||
|
//@media (max-width: 1280px) {
|
||||||
|
// grid-template-columns: repeat(4, 1fr);
|
||||||
|
//}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-cell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
//transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbnail {
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom-button {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background-color:rgba(0,0,0, 0);
|
||||||
|
transition: background-color 0.6s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color:rgba(0,0,0, 0.35);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
transform: scale(1.4);
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 20%;
|
||||||
|
height: 20%;
|
||||||
|
color: rgba(255, 255, 255, 0.2);
|
||||||
|
transition: transform 0.6s, color 0.6s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.thumbnail--no-img {
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
height: 100%;
|
||||||
|
padding-top: calc(50% - 75px);
|
||||||
|
|
||||||
|
> svg {
|
||||||
|
width: 75px;
|
||||||
|
height: 75px;
|
||||||
|
color: $accentColor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.details {
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(-4em + 5px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 4em;
|
||||||
|
width: 80%;
|
||||||
|
margin: 0 auto;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
.name {
|
||||||
|
margin-top: 15px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-box {
|
||||||
|
padding: 0 30px;
|
||||||
|
height: 60px;
|
||||||
|
line-height: 60px;
|
||||||
|
background: lighten(red, 46);
|
||||||
|
border: 2px solid crimson;
|
||||||
|
border-radius: $borderRadius;
|
||||||
|
color: crimson;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
margin: 15px auto 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#page-loader {
|
||||||
|
position: absolute;
|
||||||
|
padding-left: 50%;
|
||||||
|
padding-top: 120px;
|
||||||
|
|
||||||
|
> span > span {
|
||||||
|
background-color: $accentColor !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import {Album} from "../../models/album";
|
||||||
|
import {Instance} from "../../models/instance";
|
||||||
|
|
||||||
|
const apiUrl = window._env_.REACT_APP_API_URL;
|
||||||
|
|
||||||
|
export default function usePublicApi() {
|
||||||
|
|
||||||
|
return {
|
||||||
|
fetchAlbums
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return {Promise<{instance: Instance, albums: Album[]}>}
|
||||||
|
*/
|
||||||
|
async function fetchAlbums() {
|
||||||
|
const response = await handleResponse(await fetch(`${apiUrl}/api/albums`));
|
||||||
|
return {
|
||||||
|
instance: new Instance(response.data.instance),
|
||||||
|
albums: response.data.albums.map(album => new Album(album))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleResponse(response) {
|
||||||
|
const text = await response.text()
|
||||||
|
|
||||||
|
const data = text && JSON.parse(text);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = (data && data.message) || response.statusText;
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import React, {useEffect, useState} from "react";
|
||||||
|
import usePublicApi from "../../public/services/public-api";
|
||||||
|
|
||||||
|
export const CurrentInstanceContext = React.createContext(
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
export const CurrentInstanceProvider = ({children}) => {
|
||||||
|
const {fetchAlbums} = usePublicApi();
|
||||||
|
|
||||||
|
const [initialized, setInitialized] = useState(false);
|
||||||
|
const [instance, setInstance] = useState(null);
|
||||||
|
const [albums, setAlbums] = useState([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [apiError, setApiError] = useState(null);
|
||||||
|
const [selectedAlbum, setSelectedAlbum] = useState(null);
|
||||||
|
const [selectedPhotoIndex, setSelectedPhotoIndex] = useState(-1);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void (async () => {
|
||||||
|
if (fetchAlbums && !initialized) {
|
||||||
|
setInitialized(true);
|
||||||
|
try {
|
||||||
|
setApiError(null);
|
||||||
|
const {instance, albums} = await fetchAlbums();
|
||||||
|
document.title = `${instance.title} | ${instance.subtitle}`;
|
||||||
|
setAlbums(albums);
|
||||||
|
setInstance(instance);
|
||||||
|
setIsLoading(false);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Could not fetch albums", err);
|
||||||
|
setApiError("An error occurred trying to fetch your albums, please contact the system administrator");
|
||||||
|
setIsLoading(false);
|
||||||
|
setAlbums([]);
|
||||||
|
setInstance(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [fetchAlbums, initialized]);
|
||||||
|
|
||||||
|
const value = {
|
||||||
|
instance,
|
||||||
|
setInstance,
|
||||||
|
albums,
|
||||||
|
setAlbums,
|
||||||
|
isLoading,
|
||||||
|
setIsLoading,
|
||||||
|
apiError,
|
||||||
|
setApiError,
|
||||||
|
selectedAlbum,
|
||||||
|
setSelectedAlbum,
|
||||||
|
selectedPhotoIndex,
|
||||||
|
setSelectedPhotoIndex
|
||||||
|
};
|
||||||
|
|
||||||
|
return <CurrentInstanceContext.Provider value={value}>{children}</CurrentInstanceContext.Provider>;
|
||||||
|
};
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import {CurrentInstanceContext} from "./current-instance-context";
|
||||||
|
import {useContext} from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return {InstancesContext}
|
||||||
|
*/
|
||||||
|
export const useCurrentInstance = () => {
|
||||||
|
const context = useContext(CurrentInstanceContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error(
|
||||||
|
'Current instance context is undefined, please verify you are calling useCurrentInstance() as child of a <CurrentInstanceProvider> component.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
};
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
export const createAnimation = (loaderName, frames, suffix) => {
|
||||||
|
const animationName = `react-spinners-${loaderName}-${suffix}`;
|
||||||
|
|
||||||
|
if (typeof window == "undefined" || !window.document) {
|
||||||
|
return animationName;
|
||||||
|
}
|
||||||
|
|
||||||
|
const styleEl = document.createElement("style");
|
||||||
|
document.head.appendChild(styleEl);
|
||||||
|
const styleSheet = styleEl.sheet;
|
||||||
|
|
||||||
|
const keyFrames = `
|
||||||
|
@keyframes ${animationName} {
|
||||||
|
${frames}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (styleSheet) {
|
||||||
|
styleSheet.insertRule(keyFrames, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return animationName;
|
||||||
|
};
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
$headerSize: 60px;
|
||||||
|
$pageMargin: 15px;
|
||||||
|
$headerOffset: 25px;
|
||||||
|
$borderColor: #999;
|
||||||
|
$borderRadius: 30px;
|
||||||
|
$backgroundColor: #fff;
|
||||||
|
$accentColor: #de5200;
|
||||||
|
|
||||||
|
$sidebar_mobile_breakpoint: 768px;
|
||||||
|
|
||||||
|
$sidebar_mobile_height: 30vh;
|
||||||
|
|
||||||
|
$sidebar_width_1920: 15vw;
|
||||||
|
$sidebar_width_1024_1920: 25vw;
|
||||||
|
$sidebar_width_768_1024: 35vw;
|
||||||
|
$sidebar_width_480_768: 50vw;
|
||||||
Generated
+1215
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "photos",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Novox photos manager application",
|
||||||
|
"scripts": {
|
||||||
|
"bootstrap": "npm ci && cd client && npm ci && cd ../server && npm ci",
|
||||||
|
"dev": "npm-run-all -p dev-client dev-server",
|
||||||
|
"dev-client": "cd client && npm run dev",
|
||||||
|
"dev-server": "cd server && npm run dev",
|
||||||
|
"build": "npm-run-all -p build-client build-server",
|
||||||
|
"build-client": "cd client && npm run build",
|
||||||
|
"build-server": "cd server && npm run build"
|
||||||
|
},
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"npm-run-all": "^4.1.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/node_modules
|
||||||
|
/dist
|
||||||
Executable
+10
@@ -0,0 +1,10 @@
|
|||||||
|
FROM node:21.2.0
|
||||||
|
|
||||||
|
# Create app directory
|
||||||
|
WORKDIR /usr/src/photos-server.novox.be
|
||||||
|
|
||||||
|
# Copy build output
|
||||||
|
COPY ./dist ./
|
||||||
|
COPY ./node_modules ./node_modules
|
||||||
|
|
||||||
|
CMD ["node", "server.cjs", "--enable-source-maps"]
|
||||||
Generated
+3553
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "photos-api",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "npm-run-all -p watch nodemon",
|
||||||
|
"nodemon": "nodemon --watch dist --enable-source-maps --inspect dist/server.cjs",
|
||||||
|
"start": "node src/server.cjs",
|
||||||
|
"build": "rollup --config",
|
||||||
|
"watch": "rollup --config --watch",
|
||||||
|
"docker-build": "./scripts/docker-build.sh",
|
||||||
|
"docker-run": "./scripts/docker-run.sh"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/preset-typescript": "^7.23.3",
|
||||||
|
"@koa/multer": "^3.0.2",
|
||||||
|
"@koa/router": "^12.0.1",
|
||||||
|
"image-size": "^1.0.2",
|
||||||
|
"koa": "^2.14.2",
|
||||||
|
"koa-bodyparser": "^4.4.1",
|
||||||
|
"minio": "^7.1.3",
|
||||||
|
"mongodb": "^6.3.0",
|
||||||
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"reflect-metadata": "^0.1.13",
|
||||||
|
"sharp": "^0.33.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@rollup/plugin-commonjs": "^25.0.7",
|
||||||
|
"@rollup/plugin-json": "^6.0.1",
|
||||||
|
"@rollup/plugin-node-resolve": "^15.2.3",
|
||||||
|
"nodemon": "^3.0.1",
|
||||||
|
"rollup": "^4.5.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user