Explorar el Código

Initial commit

master
jochen hace 1 año
commit
a8fdb455f4
Se han modificado 100 ficheros con 45032 adiciones y 0 borrados
  1. +86
    -0
      .drone.yml
  2. +2
    -0
      .gitignore
  3. +41
    -0
      README.md
  4. +1
    -0
      admin-client/.env
  5. +27
    -0
      admin-client/.gitignore
  6. +26
    -0
      admin-client/Dockerfile
  7. +13
    -0
      admin-client/conf/conf.d/default.conf
  8. +24
    -0
      admin-client/conf/conf.d/gzip.conf
  9. +17850
    -0
      admin-client/package-lock.json
  10. +47
    -0
      admin-client/package.json
  11. BIN
      admin-client/public/favicon.ico
  12. +45
    -0
      admin-client/public/index.html
  13. BIN
      admin-client/public/logo192.png
  14. BIN
      admin-client/public/logo512.png
  15. +25
    -0
      admin-client/public/manifest.json
  16. +3
    -0
      admin-client/public/robots.txt
  17. +1
    -0
      admin-client/scripts/docker-build.sh
  18. +9
    -0
      admin-client/scripts/docker-run.sh
  19. +32
    -0
      admin-client/scripts/env.sh
  20. +242
    -0
      admin-client/src/admin.scss
  21. +53
    -0
      admin-client/src/components/Loader.js
  22. +44
    -0
      admin-client/src/components/drag-and-drop/drag-and-drop.js
  23. +21
    -0
      admin-client/src/components/drag-and-drop/drag-and-drop.scss
  24. +49
    -0
      admin-client/src/components/header.js
  25. +69
    -0
      admin-client/src/components/header.scss
  26. +0
    -0
      admin-client/src/components/loader.scss
  27. +16
    -0
      admin-client/src/components/logo.js
  28. +20
    -0
      admin-client/src/hooks/outside-click-alerter.js
  29. +32
    -0
      admin-client/src/index.css
  30. +55
    -0
      admin-client/src/index.js
  31. +68
    -0
      admin-client/src/layout.js
  32. +128
    -0
      admin-client/src/mixins.scss
  33. +34
    -0
      admin-client/src/models/album.js
  34. +12
    -0
      admin-client/src/models/instance.js
  35. +29
    -0
      admin-client/src/models/photo.js
  36. +302
    -0
      admin-client/src/pages/album-manager.js
  37. +119
    -0
      admin-client/src/pages/album-manager.scss
  38. +197
    -0
      admin-client/src/pages/instance-selector-page.js
  39. +46
    -0
      admin-client/src/pages/instance-selector-page.scss
  40. +82
    -0
      admin-client/src/pages/manage-instance-page.js
  41. +3
    -0
      admin-client/src/pages/no-access.js
  42. +210
    -0
      admin-client/src/pages/portfolio-manager.js
  43. +41
    -0
      admin-client/src/pages/portfolio-manager.scss
  44. +134
    -0
      admin-client/src/services/albums-api.js
  45. +236
    -0
      admin-client/src/services/authentication/auth-context.js
  46. +17
    -0
      admin-client/src/services/authentication/keycloak.js
  47. +14
    -0
      admin-client/src/services/authentication/use-auth.js
  48. +69
    -0
      admin-client/src/services/authorized-api.js
  49. +63
    -0
      admin-client/src/services/instances/instances-api.js
  50. +78
    -0
      admin-client/src/services/instances/instances-context.js
  51. +17
    -0
      admin-client/src/services/instances/use-instances.js
  52. +23
    -0
      admin-client/src/utils/animation.js
  53. +16
    -0
      admin-client/src/variables.scss
  54. +1
    -0
      client/.env
  55. +27
    -0
      client/.gitignore
  56. +26
    -0
      client/Dockerfile
  57. +13
    -0
      client/conf/conf.d/default.conf
  58. +24
    -0
      client/conf/conf.d/gzip.conf
  59. +17873
    -0
      client/package-lock.json
  60. +47
    -0
      client/package.json
  61. BIN
      client/public/favicon.ico
  62. +45
    -0
      client/public/index.html
  63. BIN
      client/public/logo192.png
  64. BIN
      client/public/logo512.png
  65. +25
    -0
      client/public/manifest.json
  66. +3
    -0
      client/public/robots.txt
  67. +1
    -0
      client/scripts/docker-build.sh
  68. +9
    -0
      client/scripts/docker-run.sh
  69. +32
    -0
      client/scripts/env.sh
  70. +53
    -0
      client/src/components/Loader.js
  71. +0
    -0
      client/src/components/loader.scss
  72. +128
    -0
      client/src/components/photo-viewer/photo-viewer.js
  73. +74
    -0
      client/src/components/photo-viewer/photo-viewer.scss
  74. +20
    -0
      client/src/hooks/outside-click-alerter.js
  75. +32
    -0
      client/src/index.css
  76. +45
    -0
      client/src/index.js
  77. +16
    -0
      client/src/logo.js
  78. +128
    -0
      client/src/mixins.scss
  79. +34
    -0
      client/src/models/album.js
  80. +11
    -0
      client/src/models/instance.js
  81. +29
    -0
      client/src/models/photo.js
  82. +5
    -0
      client/src/public/context/pageTransition.js
  83. +57
    -0
      client/src/public/layout.js
  84. +7
    -0
      client/src/public/pages/access-denied.js
  85. +53
    -0
      client/src/public/pages/album.js
  86. +23
    -0
      client/src/public/pages/home-page.js
  87. +99
    -0
      client/src/public/pages/home-page.scss
  88. +33
    -0
      client/src/public/pages/portfolio.js
  89. +308
    -0
      client/src/public/public.scss
  90. +34
    -0
      client/src/public/services/public-api.js
  91. +57
    -0
      client/src/services/current-instance/current-instance-context.js
  92. +17
    -0
      client/src/services/current-instance/use-current-instance.js
  93. +23
    -0
      client/src/utils/animation.js
  94. +16
    -0
      client/src/variables.scss
  95. +1215
    -0
      package-lock.json
  96. +19
    -0
      package.json
  97. +2
    -0
      server/.gitignore
  98. +10
    -0
      server/Dockerfile
  99. +3553
    -0
      server/package-lock.json
  100. +34
    -0
      server/package.json

+ 86
- 0
.drone.yml Ver fichero

@@ -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

+ 2
- 0
.gitignore Ver fichero

@@ -0,0 +1,2 @@
.idea
node_modules

+ 41
- 0
README.md Ver fichero

@@ -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`

+ 1
- 0
admin-client/.env Ver fichero

@@ -0,0 +1 @@
REACT_APP_API_URL=http://localhost:9001

+ 27
- 0
admin-client/.gitignore Ver fichero

@@ -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

+ 26
- 0
admin-client/Dockerfile Ver fichero

@@ -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;\""]

+ 13
- 0
admin-client/conf/conf.d/default.conf Ver fichero

@@ -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;
}
}

+ 24
- 0
admin-client/conf/conf.d/gzip.conf Ver fichero

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

+ 17850
- 0
admin-client/package-lock.json
La diferencia del archivo ha sido suprimido porque es demasiado grande
Ver fichero


+ 47
- 0
admin-client/package.json Ver fichero

@@ -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"
}
}

BIN
admin-client/public/favicon.ico Ver fichero

Antes Después

+ 45
- 0
admin-client/public/index.html Ver fichero

@@ -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>

BIN
admin-client/public/logo192.png Ver fichero

Antes Después
Anchura: 192  |  Altura: 192  |  Tamaño: 5.2KB

BIN
admin-client/public/logo512.png Ver fichero

Antes Después
Anchura: 512  |  Altura: 512  |  Tamaño: 9.4KB

+ 25
- 0
admin-client/public/manifest.json Ver fichero

@@ -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"
}

+ 3
- 0
admin-client/public/robots.txt Ver fichero

@@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

+ 1
- 0
admin-client/scripts/docker-build.sh Ver fichero

@@ -0,0 +1 @@
docker build . -t photos-admin-client

+ 9
- 0
admin-client/scripts/docker-run.sh Ver fichero

@@ -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

+ 32
- 0
admin-client/scripts/env.sh Ver fichero

@@ -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

+ 242
- 0
admin-client/src/admin.scss Ver fichero

@@ -0,0 +1,242 @@
@import "variables";

#admin-page {
margin: 0 auto;
width: 100%;
max-width: 1280px;
flex: 1;
display: flex;
flex-direction: column;
padding: 0 $pageMargin $pageMargin;

#page {
position: relative;
background: #f6f6f6;
flex: 1;
border: 2px solid $accentColor;
border-radius: $borderRadius;
padding: $borderRadius;
}

#page-title {
font-size: 1.8rem;
line-height: 2em;
margin-bottom: 25px;
display: flex;
align-items: center;
justify-content: space-between;

#left-part {
display: flex;
justify-content: flex-start;

button {
padding: 0;
font-size: 2rem;
height: 1.8em;

svg#navigate-back {
width: 1.8em;
height: 1.8em;
}
}

.input-element {
margin-bottom: 0;
margin-left: 15px;
}

.photos-count {
font-size: 0.6em;
line-height: 2em;
color: #333;
font-style: italic;
margin-left: 15px;
}
}

.MuiInput-input {
font-size: 2rem;
}
}

#page-loader {
position: absolute;
padding-left: 50%;
padding-top: 120px;

> span > span {
background-color: $accentColor !important;
}
}

.card-layout {
padding: 15px;
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: space-between;
border: 2px solid $borderColor;
border-radius: $borderRadius;
position: relative;

.cover-photo {
position: relative;
height: 350px;
flex: 0 0 350px;
margin-bottom: 3px;
cursor: pointer;
width: 100%;
max-width: 100%;
border-radius: $borderRadius;
box-shadow: #333 5px 5px 5px;
overflow: hidden;

img {
width: 100%;
transform: scale(1);
}

&:hover {
img,
svg {
transform: scale(1.05);
}
}

.file-name {
position: absolute;
bottom: 0;
background: white;
width: 100%;
text-align: center;
font-size: 0.8em;
font-style: italic;
line-height: 1.2em;
padding: 0 20px;
}

&.cover-photo--no-img {
//padding-left: calc(350px / 2 - 37.5px);
display: flex;
align-items: center;
flex-direction: column;
justify-content: center;

#page-loader {
padding: 9px;
}

svg.no-photos {
width: 75px;
height: 75px;
color: $accentColor;
}
}
}

.card-details {
flex: 1 1 auto;
margin-left: 25px;
margin-top: 25px;

@media(max-width: 968px) {
margin-left: 0;
width: 100%;
max-width: 450px;
}

.input-element {
margin-bottom: 25px;

.input-error {
visibility: hidden;
position: absolute;
bottom: -1.4em;
left: 25px;
font-size: 11pt;
color: red;
font-style: italic;
}
}
}

.input-buttons {
display: flex;
justify-content: space-between;

@media(max-width: 968px) {
flex-direction: column;
}

.left-buttons {
display: flex;

button {
margin-right: 15px;
}

@media(max-width: 968px) {
justify-content: space-between;
margin-bottom: 15px;
button {
margin-right: 0;
}
}
}

.right-buttons {
display: flex;

button {
margin-left: 15px;
}

@media(max-width: 968px) {
justify-content: space-between;
margin-bottom: 15px;

button {
margin-left: 0;
}
}
}
}
}

.upload-error {
width: 100%;
padding: 25px;
bottom: 0;
text-align: center;
position: absolute;
font-size: 11pt;
color: red;
font-style: italic;
}

.upload-success {
width: 100%;
padding: 25px;
bottom: 0;
text-align: center;
position: absolute;
font-size: 11pt;
color: green;
font-style: italic;
}

#photosUpload {
display: none;
}

.error-box {
padding: 15px;
background: lighten(red, 46);
border: 2px solid crimson;
border-radius: $borderRadius;
color: crimson;
font-size: 1rem;
margin-bottom: 15px;
}
}

+ 53
- 0
admin-client/src/components/Loader.js Ver fichero

@@ -0,0 +1,53 @@
import {createAnimation} from "../utils/animation";

const sync = createAnimation(
"SyncLoader",
`33% {transform: translateY(10px)}
66% {transform: translateY(-10px)}
100% {transform: translateY(0)}`,
"sync"
);

function Loader({
loading = false,
color = "#000000",
speedMultiplier = 1,
cssOverride = {},
size = 15,
margin = 2,
...additionalprops
}) {
const wrapper = {
display: "inherit",
...cssOverride,
};

const style = (i) => {
return {
backgroundColor: color,
width: size,
height: size,
margin: margin,
borderRadius: "100%",
display: "inline-block",
animation: `${sync} ${0.6 / speedMultiplier}s ${i * 0.07}s infinite ease-in-out`,
animationFillMode: "both",
};
};

if (!loading) {
return null;
}

return (
<div id="page-loader">
<span style={wrapper} {...additionalprops}>
<span style={style(1)}/>
<span style={style(2)}/>
<span style={style(3)}/>
</span>
</div>
);
}

export default Loader;

+ 44
- 0
admin-client/src/components/drag-and-drop/drag-and-drop.js Ver fichero

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

+ 21
- 0
admin-client/src/components/drag-and-drop/drag-and-drop.scss Ver fichero

@@ -0,0 +1,21 @@
.draggable {
position: relative;

&.draggable--dragging {
.draggable-content {
transition: 0.3s;
scale: 1.2
}
}

.draggable-overlay {
position: absolute;
background: transparent;
left: 0;
top: 0;
height: 100%;
width: 100%;
border-radius: 25px;
z-index: 9999;
}
}

+ 49
- 0
admin-client/src/components/header.js Ver fichero

@@ -0,0 +1,49 @@
import {useOutsideAlerter} from "../hooks/outside-click-alerter";
import * as React from "react";
import {useNavigate} from "react-router-dom";
import {useRef, useState} from "react";
import {Edit, Person} from '@mui/icons-material';
import {Logo} from "./logo";
import {Button, IconButton} from "@mui/material";

import "./header.scss";
import {useAuth} from "../services/authentication/use-auth";
import {useInstances} from "../services/instances/use-instances";

export default function Header() {
const {selectedInstance} = useInstances();
const navigate = useNavigate();
const {userData, userManager} = useAuth();
const [showUserDropout, setShowUserDropout] = useState(false);

const wrapperRef = useRef(null);
useOutsideAlerter(wrapperRef, () => {
setShowUserDropout(false);
});

const handleSignOut = async () => {
await userManager.signoutRedirect();
navigate("/");
}

const handleUserIconClicked = () => {
setShowUserDropout(!showUserDropout);
}

return <header id="admin-header" ref={wrapperRef}>
<div id="logo" onClick={() => navigate("/")}><Logo/></div>
{selectedInstance && <Button variant="contained" onClick={() => navigate("/")} color="warning">
<Edit /> {selectedInstance.title}
</Button>}
{userData &&
<div id="user-icon">
<IconButton aria-label="person" onClick={handleUserIconClicked}>
<Person/>
</IconButton>
{showUserDropout && userData && <div id="user-icon-dropout">
<div>{userData.profile?.email}</div>
{userData ? <div><Button variant="contained" onClick={handleSignOut}>Sign out</Button></div> : null}
</div>}
</div>}
</header>;
}

+ 69
- 0
admin-client/src/components/header.scss Ver fichero

@@ -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
admin-client/src/components/loader.scss Ver fichero


+ 16
- 0
admin-client/src/components/logo.js Ver fichero

@@ -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>;
}

+ 20
- 0
admin-client/src/hooks/outside-click-alerter.js Ver fichero

@@ -0,0 +1,20 @@
import {useEffect} from "react";

/**
* Hook that alerts clicks outside of the passed ref
*/
export function useOutsideAlerter(ref, handler) {
useEffect(() => {
function handleClickOutside(event) {
if (ref.current && !ref.current.contains(event.target)) {
handler(event);
}
}
// Bind the event listener
document.addEventListener("mousedown", handleClickOutside);
return () => {
// Unbind the event listener on clean up
document.removeEventListener("mousedown", handleClickOutside);
};
}, [ref, handler]);
}

+ 32
- 0
admin-client/src/index.css Ver fichero

@@ -0,0 +1,32 @@
body {
margin: 0;
font-size: 16pt;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}

#root {
background: #fff;
min-height: 100vh;
font-size: 1rem;
display: flex;
flex-direction: column;
}

*,
*::before,
*::after {
box-sizing: border-box;
}

input {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
}

+ 55
- 0
admin-client/src/index.js Ver fichero

@@ -0,0 +1,55 @@
import React, {useLayoutEffect} from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import {BrowserRouter, Navigate, Route, Routes, useLocation} from "react-router-dom";

import '@fontsource/roboto/300.css';
import '@fontsource/roboto/400.css';
import '@fontsource/roboto/500.css';
import '@fontsource/roboto/700.css';

import Keycloak from "./services/authentication/keycloak";
import PortfolioManager from "./pages/portfolio-manager";
import AlbumManager from "./pages/album-manager";
import {InstanceSelectorPage} from "./pages/instance-selector-page";
import {NoAccessPage} from "./pages/no-access";
import {InstancesProvider} from "./services/instances/instances-context";
import {AdminLayout} from "./layout";

const root = ReactDOM.createRoot(document.getElementById('root'));

root.render(
<React.StrictMode>
<BrowserRouter>
<ScrollToTop>
<Keycloak>
<InstancesProvider>
<AdminLayout>
<Routes>
<Route index path="/" element={<InstanceSelectorPage/>}/>
<Route path="/no-access" element={<NoAccessPage/>}/>
<Route path="/:instanceId">
{/*<Route index element={<ManageInstancePage/>}/>*/}
<Route index path="" element={<Navigate to="albums" />} />
<Route path="albums" element={<PortfolioManager/>} />
<Route path="albums/:albumId" element={<AlbumManager/>}/>
<Route path="signin-keycloak" element={<Navigate to="/"/>}/>
<Route path="signin-keycloak" element={<Navigate to="/"/>}/>
</Route>
<Route path="*" element={<Navigate to={"/no-access"}/>}/>
</Routes>
</AdminLayout>
</InstancesProvider>
</Keycloak>
</ScrollToTop>
</BrowserRouter>
</React.StrictMode>
);

function ScrollToTop({children}) {
const location = useLocation();
useLayoutEffect(() => {
document.documentElement.scrollTo(0, 0);
}, [location.pathname]);
return children
}

+ 68
- 0
admin-client/src/layout.js Ver fichero

@@ -0,0 +1,68 @@
import * as React from "react";
import Header from "./components/header";
import {useNavigate} from "react-router-dom";
import {useEffect} from "react";

import "./admin.scss";
import Loader from "./components/Loader";

import {createTheme} from '@mui/material/styles';
import {ThemeProvider} from "@mui/material";
import {useAuth} from "./services/authentication/use-auth";
import {useInstances} from "./services/instances/use-instances";

const theme = createTheme({
palette: {
primary: {
main: '#de5200'
}
},
});


export function AdminLayout({children}) {
const navigate = useNavigate();
const {isLoading, userData, userManager, signIn} = useAuth();
const {isLoading: instancesIsLoading} = useInstances();

useEffect(() => {
async function handleLogin() {
try {
await userManager.signinSilentCallback();
} catch (err) {
}
}

async function silentLogin() {
try {
await userManager.signinSilent();
} catch (err) {
await signIn();
}
}

if (!userData && !isLoading) {
if (window.location.href.indexOf("/signin-keycloak") >= 0) {
handleLogin();
} else if (window.location.href.indexOf("/signin-keycloak") >= 0) {
navigate("/");
} else {
silentLogin();
}
}
}, [userData, isLoading, userManager, navigate, signIn]);

return <ThemeProvider theme={theme}>
<Header/>
<div id="admin-page">
<main id="page">
{userData ? <>
{(isLoading || instancesIsLoading) && <Loader/>}
{!isLoading && !instancesIsLoading && children}
</>
: <Loader/>
}
</main>
</div>
</ThemeProvider>
}

+ 128
- 0
admin-client/src/mixins.scss Ver fichero

@@ -0,0 +1,128 @@
@import "./variables";

@keyframes sb_slide_negative_left_to_right {
from {
transform: translateX(-$sidebar_width_1920);
@media(max-width: 1920px) {
transform: translateX(-$sidebar_width_1024_1920);
}
@media(max-width: 1024px) {
transform: translateX(-$sidebar_width_768_1024);
}
@media(max-width: 768px) {
transform: translateX(-$sidebar_width_480_768);
}
}

to {
transform: translateX(0);
}
}

@mixin sb_slide_negative_left_to_right() {
transition: 0.6s;
animation: ease-out;
animation-name: sb_slide_negative_left_to_right;
transform: translateX(0);
}


@keyframes sb_slide_right_to_negative_left {
from {
transform: translateX(0);
}

to {
transform: translateX(-$sidebar_width_1920);
@media(max-width: 1920px) {
transform: translateX(-$sidebar_width_1024_1920);
}
@media(max-width: 1024px) {
transform: translateX(-$sidebar_width_768_1024);
}
@media(max-width: 768px) {
transform: translateX(-$sidebar_width_480_768);
}
}
}

@mixin sb_slide_right_to_negative_left() {
transition: 0.6s;
animation: ease-out;
animation-name: sb_slide_right_to_negative_left;

transform: translateX(-1 * $sidebar_width_1920);
@media(max-width: 1920px) {
transform: translateX(-1 * $sidebar_width_1024_1920);
}
@media(max-width: 1024px) {
transform: translateX(-1 * $sidebar_width_768_1024);
}
@media(max-width: 768px) {
transform: translateX(-1 * $sidebar_width_480_768);
}
}

@keyframes sb_slide_left_to_right {
from {
transform: translateX(0);
}

to {
transform: translateX($sidebar_width_1920);
@media(max-width: 1920px) {
transform: translateX($sidebar_width_1024_1920);
}
@media(max-width: 1024px) {
transform: translateX($sidebar_width_768_1024);
}
@media(max-width: 768px) {
transform: translateX($sidebar_width_480_768);
}
}
}

@mixin sb_slide_left_to_right() {
transition: 0.6s;
animation: ease-out;
animation-name: sb_slide_left_to_right;

transform: translateX($sidebar_width_1920);
@media(max-width: 1920px) {
transform: translateX($sidebar_width_1024_1920);
}
@media(max-width: 1024px) {
transform: translateX($sidebar_width_768_1024);
}
@media(max-width: 768px) {
transform: translateX($sidebar_width_480_768);
}
}


@keyframes sb_slide_right_to_left {
from {
transform: translateX($sidebar_width_1920);
@media(max-width: 1920px) {
transform: translateX($sidebar_width_1024_1920);
}
@media(max-width: 1024px) {
transform: translateX($sidebar_width_768_1024);
}
@media(max-width: 768px) {
transform: translateX($sidebar_width_480_768);
}
}

to {
transform: translateX(0);
}
}

@mixin sb_slide_right_to_left() {
transition: 0.6s;
animation: ease-out;
animation-name: sb_slide_right_to_left;

transform: translateX(0);
}

+ 34
- 0
admin-client/src/models/album.js Ver fichero

@@ -0,0 +1,34 @@
import {Photo} from "./photo.js";

export class Album {
/**
* @param {Album} [album]
*/
constructor(album) {
this._id = album?._id;
this.albumId = album?.albumId;
this.name = album?.name;
this.description = album?.description;
this.sort = album?.sort;
this.sortOrder = album?.sortOrder;
this.photos = album?.photos?.map(photo => new Photo(photo)) || [];
this.coverPhoto = this.photos.length > 0 ? this.photos[0] : null;
}

/** @type {string} **/
id;
/** @type {string} **/
albumId;
/** @type {string} **/
name;
/** @type {string} **/
description;
/** @type {"manual"|"auto"} **/
sort;
/** @type {number} **/
sortOrder;
/** @type {Photo[]} **/
photos;
/** @type {Photo} **/
coverPhoto;
}

+ 12
- 0
admin-client/src/models/instance.js Ver fichero

@@ -0,0 +1,12 @@
import {Photo} from "./photo";

export class Instance {
constructor(instance) {
this._id = instance?._id;
this.instanceId = instance?.instanceId;
this.title = instance?.title;
this.subtitle = instance?.subtitle;
this.coverPhoto = instance?.coverPhoto ? new Photo(instance.coverPhoto) : null;
this.urls = instance?.urls?.map(u => u) || [];
}
}

+ 29
- 0
admin-client/src/models/photo.js Ver fichero

@@ -0,0 +1,29 @@
export class Photo {
/**
* @param {Photo} photo
*/
constructor(photo) {
this.photoId = photo?.photoId;
this.albumId = photo?.albumId;
this.name = photo?.name;
this.fileName = photo?.fileName;
this.mimeType = photo?.mimeType;
this.s3Path = photo?.s3Path;
this.description = photo?.description;
this.sortOrder = photo?.sortOrder;
this.url = `${window._env_.REACT_APP_API_URL}/api/s3/${this.s3Path}`;
}

/** @type {string} **/
photoId;
/** @type {string} **/
albumId;
/** @type {string} **/
name;
/** @type {string} **/
s3Path;
/** @type {string} **/
description;
/** @type {number} **/
sortOrder;
}

+ 302
- 0
admin-client/src/pages/album-manager.js Ver fichero

@@ -0,0 +1,302 @@
import * as React from "react";
import {useLocation, useNavigate} from "react-router-dom";
import {AddAPhoto, ArrowCircleLeftOutlined, MoveDown, MoveUp} from "@mui/icons-material";
import {useRef, useState} from "react";
import useAlbumsApi from "../services/albums-api";
import {Button, IconButton, Input, TextField} from "@mui/material";
import {Photo} from "../models/photo";
import DragAndDrop from "../components/drag-and-drop/drag-and-drop";
import {Album} from "../models/album";
import Loader from "../components/Loader";

import "./album-manager.scss";
import {useInstances} from "../services/instances/use-instances";

function AlbumManager() {
const {state} = useLocation();
const {album} = state; // Read values passed on state
const {selectedInstance} = useInstances();

const navigate = useNavigate();

const {
postAlbum,
patchAlbum,
patchPhoto,
uploadPhotos,
deletePhoto,
movePhotoDown,
movePhotoUp
} = useAlbumsApi();

const [name, setName] = useState(album.name || '');
const [description, setDescription] = useState(album.description || '');
const [sort] = useState(album.sort || "auto" );

const [photosToUpdate, setPhotosToUpdate] = useState(album.photos.map(x => new Photo(x)));
const [uploadApiError, setUploadApiError] = useState(null);
const [apiError, setApiError] = useState(null);
const [uploadSuccess, setUploadSuccess] = useState(null);
const [isUploading, setIsUploading] = useState(false);

const fileInputRef = useRef();

const handleSaveEdit = async (e) => {
try {
if (hasChanges()) {
setApiError(null);
let albumToUpdate = new Album({
...album,
name,
description
});
let updatedAlbum;
if (album.albumId) {
updatedAlbum = await patchAlbum(selectedInstance, albumToUpdate);
} else {
updatedAlbum = await postAlbum(selectedInstance, albumToUpdate);
}
navigate(`/${selectedInstance.instanceId}/albums/${updatedAlbum.albumId}`, {state: {album: updatedAlbum}});
}
} catch (err) {
if (typeof err === "string" && err.toLowerCase().indexOf("duplicate") >= 0) {
setApiError("Name already in use");
} else {
console.error(err);
setApiError("An error occurred trying to save the album, contact the system administrator");
}
}
}

const handleSavePhotoEdit = async (idx) => {
try {
setApiError(null);
if (photoHasChanges(idx)) {
const updatedAlbum = await patchPhoto(
selectedInstance,
album.albumId,
photosToUpdate[idx]
);
navigate(`/${selectedInstance.instanceId}/albums/${updatedAlbum.albumId}`, {state: {album: updatedAlbum}});
}
} catch (err) {
if (typeof err === "string" && err.toLowerCase().indexOf("duplicate") >= 0) {
setApiError("Name already in use");
} else {
console.error(err);
setApiError("An error occurred trying to save the photo, contact the system administrator");
}
}
}

const handleMovePhotoDown = async (idx) => {
try {
setApiError(null);
const updatedAlbum = await movePhotoDown(
selectedInstance,
album.albumId,
photosToUpdate[idx].photoId
);
const orgPhoto = photosToUpdate[idx];
photosToUpdate[idx] = photosToUpdate[idx + 1];
photosToUpdate[idx + 1] = orgPhoto;
setPhotosToUpdate([...photosToUpdate]);
navigate(`/${selectedInstance.instanceId}/albums/${updatedAlbum.albumId}`, {state: {album: updatedAlbum}});
} catch (err) {
console.error(err);
setApiError("An error occurred trying to save the photo, contact the system administrator");
}
}

const handleMovePhotoUp = async (idx) => {
try {
setApiError(null);
const updatedAlbum = await movePhotoUp(
selectedInstance,
album.albumId,
photosToUpdate[idx].photoId
);
const orgPhoto = photosToUpdate[idx];
photosToUpdate[idx] = photosToUpdate[idx - 1];
photosToUpdate[idx - 1] = orgPhoto;
setPhotosToUpdate([...photosToUpdate]);
navigate(`/${selectedInstance.instanceId}/albums/${updatedAlbum.albumId}`, {state: {album: updatedAlbum}});
} catch (err) {
console.error(err);
setApiError("An error occurred trying to save the photo, contact the system administrator");
}
}

const handleDeletePhoto = async (e, photoId) => {
try {
setApiError(null);
const updatedAlbum = await deletePhoto(selectedInstance, album.albumId, photoId);
setPhotosToUpdate(updatedAlbum.photos);
navigate(`/${selectedInstance.instanceId}/albums/${updatedAlbum.albumId}`, {state: {album: updatedAlbum}});
} catch (err) {
setApiError("An error occurred trying to save the photo, contact the system administrator");
}
}

const handlePhotoNameChange = (photoIndex, e) => {
photosToUpdate[photoIndex].name = e.target.value;
setPhotosToUpdate([...photosToUpdate]);
}

const handlePhotoDescriptionChange = (photoIndex, e) => {
photosToUpdate[photoIndex].description = e.target.value;
setPhotosToUpdate([...photosToUpdate]);
}

const hasChanges = () => album.sort !== sort || album.name !== name || album.description !== description;

const photoHasChanges = (idx) => {
return (photosToUpdate[idx].name || "") !== (album.photos[idx].name || "") ||
(photosToUpdate[idx].description || "") !== (album.photos[idx].description || "");
}

const handleNavigateToOverview = (e) => {
navigate(`/${selectedInstance.instanceId}`);
}

const handleDrop = async (files) => {
await handleFiles(files)
}

const handleFiles = async (files) => {
try {
setUploadApiError(null);
setUploadSuccess(null);
setIsUploading(true);
const arrFiles = [...files];
const formData = new FormData();
for (let file of arrFiles) {
formData.append('photos', file);
}
const updatedAlbum = await uploadPhotos(selectedInstance, album.albumId, formData);
setPhotosToUpdate(updatedAlbum.photos);
setUploadSuccess(true);
navigate(`/${selectedInstance.instanceId}/albums/${updatedAlbum.albumId}`, {state: {album: updatedAlbum}});
} catch (err) {
if (typeof err === "string" && err.toLowerCase().indexOf("already exist") >= 0) {
setUploadApiError(err);
} else if (typeof err === "string" && err.toLowerCase().indexOf("mimetype") >= 0) {
setUploadApiError(err);
} else {
console.error(err);
setUploadApiError("An error occurred trying to upload your photo's, contact the system administrator");
}
} finally {
setIsUploading(false);
}
}

return selectedInstance && <div id="admin-page-manage-albums">
<div id="page-title">
<div id="left-part">
<IconButton onClick={handleNavigateToOverview} color="primary">
<ArrowCircleLeftOutlined id="navigate-back"/>
</IconButton>
<div id="edit-title" className="input-element">
<Input onKeyUp={(e) => e.key === "Enter" && handleSaveEdit(e)}
fullWidth={true}
type="text"
onChange={(e) => setName(e.target.value)}
placeholder="Name..."
tabIndex={1}
value={name}
/>
</div>
<div className="photos-count">({album.photos.length} photos)</div>
</div>
</div>
{apiError && <div className="error-box">{apiError}</div>}
{album && <div>
<div id="album-description">
<div className="input-element">
<TextField multiline={true} fullWidth={true} rows={8}
onChange={(e) => setDescription(e.target.value)}
placeholder="Description..."
tabIndex={2}
value={description}
/>
</div>
</div>
<div className="save-button">
<Button id="page-save" variant="contained" color="info" disabled={!hasChanges()}
onClick={handleSaveEdit}>
Save
</Button>
</div>
{album?.albumId &&
<div id="photo-list">
<div className="photo photo--new">
<div className="cover-photo">
<DragAndDrop onDrop={handleDrop} onClick={(e) => fileInputRef.current.click(e)}>
{!isUploading && <AddAPhoto className="no-photos" color="primary"/>}
<Loader loading={isUploading}/>
</DragAndDrop>
<input type="file" id="photosUpload" ref={fileInputRef} multiple accept="image/*"
onChange={(e) => handleFiles(e.target.files)}></input>
{uploadApiError && <div className="upload-error">{uploadApiError}</div>}
{uploadSuccess && <div className="upload-success">Photo's uploaded successfully</div>}
</div>
</div>
{photosToUpdate?.map((photo, idx) =>
<div className="card-layout photo" key={photo.photoId}>
<div className="cover-photo">
{<img src={`${photo.url}?width=450&height=450&fit=cover`} alt={photo.name}/>}
<div className="file-name">
{photo.fileName}
</div>
</div>
<div className="card-details">
<div className="title input-element">
<TextField fullWidth={true} variant="standard"
value={photo.name}
onChange={(e) => handlePhotoNameChange(idx, e)}
/>
</div>
<div className="description input-element">
<TextField multiline={true} fullWidth={true} rows={8}
onChange={(e) => handlePhotoDescriptionChange(idx, e)}
value={photo.description || ""}
/>
</div>
<div className="input-buttons">
<div className="left-buttons">
<Button className="photo-save" variant="contained" color="error"
onClick={(e) => handleDeletePhoto(e, photo.photoId)}>
Delete
</Button>
<Button className="photo-save" variant="contained" color="info"
disabled={!photoHasChanges(idx)}
onClick={() => handleSavePhotoEdit(idx)}>
Save
</Button>
</div>
<div className="right-buttons">
<Button className="photo-save"
style={{visibility: (idx > 0 ? "visible" : "hidden")}}
startIcon={<MoveUp/>} variant="contained" color="action"
onClick={() => handleMovePhotoUp(idx)}>
Move up
</Button>
<Button className="photo-save"
style={{visibility: (idx < photosToUpdate.length - 1 ? "visible" : "hidden")}}
startIcon={<MoveDown/>} variant="contained" color="action"
onClick={(e) => handleMovePhotoDown(idx)}>
Move down
</Button>
</div>
</div>
</div>
</div>
)}
</div>
}
</div>}
</div>;
}

export default AlbumManager;

+ 119
- 0
admin-client/src/pages/album-manager.scss Ver fichero

@@ -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;
}
}
}
}



}

+ 197
- 0
admin-client/src/pages/instance-selector-page.js Ver fichero

@@ -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>;
}

+ 46
- 0
admin-client/src/pages/instance-selector-page.scss Ver fichero

@@ -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;
}
}
}

}

+ 82
- 0
admin-client/src/pages/manage-instance-page.js Ver fichero

@@ -0,0 +1,82 @@
import {Button, TextField} from "@mui/material";
import * as React from "react";
import {useState} from "react";
import useInstancesApi from "../services/instances/instances-api";
import {useInstances} from "../services/instances/use-instances";

export function ManageInstancePage() {
const {selectedInstance, setSelectedInstance} = useInstances();
const {updateInstance} = useInstancesApi();
let [instanceToUpdate, setInstanceToUpdate] = useState({});
const [apiError, setApiError] = useState(null);

const handleTitleChange = (e) => {
instanceToUpdate.title = e.target.value;
setInstanceToUpdate(instanceToUpdate);
}
const handleSubtitleChange = (e) => {
instanceToUpdate.subtitle = e.target.value;
setInstanceToUpdate(instanceToUpdate);
}

const instanceHasChanges = () => {
return (instanceToUpdate.title || "") !== (selectedInstance.title || "") ||
(instanceToUpdate.subtitle || "") !== (selectedInstance.subtitle || "");
}

const handleSaveInstance = async () => {
try {
setApiError(null);
if (instanceHasChanges()) {
const updatedInstance = await updateInstance(
instanceToUpdate
);
setSelectedInstance(updatedInstance);
setInstanceToUpdate(updatedInstance);
}
} catch (err) {
console.error(err);
setApiError("An error occurred trying to save the instance, contact the system administrator");
}
}

return <>
<div className="card-layout photo">
{apiError && <div className="error-box">{apiError}</div>}
{selectedInstance && <>
{selectedInstance.photo &&
<div className="cover-photo">
{<img src={`${selectedInstance.photo.url}?width=450&height=450&fit=cover`}
alt={selectedInstance.photo.name}/>}
<div className="file-name">
{selectedInstance.photo.fileName}
</div>
</div>
}
<div className="card-details">
<div className="title input-element">
<TextField fullWidth={true} variant="standard"
value={selectedInstance.title}
onChange={handleTitleChange}
/>
</div>
<div className="description input-element">
<TextField multiline={true} fullWidth={true} rows={8}
onChange={handleSubtitleChange}
value={selectedInstance.subtitle || ""}
/>
</div>
<div className="input-buttons">
<div className="left-buttons">
<Button className="photo-save" variant="contained" color="info"
disabled={!instanceHasChanges()}
onClick={handleSaveInstance}>
Save
</Button>
</div>
</div>
</div>
</>}
</div>
</>;
}

+ 3
- 0
admin-client/src/pages/no-access.js Ver fichero

@@ -0,0 +1,3 @@
export function NoAccessPage() {
return <h1>No access!</h1>
}

+ 210
- 0
admin-client/src/pages/portfolio-manager.js Ver fichero

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

+ 41
- 0
admin-client/src/pages/portfolio-manager.scss Ver fichero

@@ -0,0 +1,41 @@
@import "../variables";

#portfolio-manager {

#page-title {
#left-part {
align-items: first baseline;
}
}

#album-list {
margin-top: 25px;
display: grid;
grid-template-columns: 1fr;
gap: 25px;

> .album-list-item {

.title {
display: flex;

.MuiFormControl-root {
margin-right: 50px;
}

.MuiInput-input {
font-size: 1.4rem;
}

.MuiButtonBase-root {
flex: 0 0 100px;
}
}

@media(max-width: 968px) {
flex-direction: column;
align-items: center;
}
}
}
}

+ 134
- 0
admin-client/src/services/albums-api.js Ver fichero

@@ -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);
}
}

+ 236
- 0
admin-client/src/services/authentication/auth-context.js Ver fichero

@@ -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
admin-client/src/services/authentication/keycloak.js Ver fichero

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

+ 14
- 0
admin-client/src/services/authentication/use-auth.js Ver fichero

@@ -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;
};

+ 69
- 0
admin-client/src/services/authorized-api.js Ver fichero

@@ -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;
}
}

+ 63
- 0
admin-client/src/services/instances/instances-api.js Ver fichero

@@ -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,
};
}

+ 78
- 0
admin-client/src/services/instances/instances-context.js Ver fichero

@@ -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>;
};

+ 17
- 0
admin-client/src/services/instances/use-instances.js Ver fichero

@@ -0,0 +1,17 @@
import {useContext} from "react";
import {InstancesContext} from "./instances-context";

/**
* @return {InstancesContext}
*/
export const useInstances = () => {
const context = useContext(InstancesContext);

if (!context) {
throw new Error(
'Instances context is undefined, please verify you are calling useInstances() as child of a <InstancesProvider> component.',
);
}

return context;
};

+ 23
- 0
admin-client/src/utils/animation.js Ver fichero

@@ -0,0 +1,23 @@
export const createAnimation = (loaderName, frames, suffix) => {
const animationName = `react-spinners-${loaderName}-${suffix}`;

if (typeof window == "undefined" || !window.document) {
return animationName;
}

const styleEl = document.createElement("style");
document.head.appendChild(styleEl);
const styleSheet = styleEl.sheet;

const keyFrames = `
@keyframes ${animationName} {
${frames}
}
`;

if (styleSheet) {
styleSheet.insertRule(keyFrames, 0);
}

return animationName;
};

+ 16
- 0
admin-client/src/variables.scss Ver fichero

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

+ 1
- 0
client/.env Ver fichero

@@ -0,0 +1 @@
REACT_APP_API_URL=http://localhost:9001

+ 27
- 0
client/.gitignore Ver fichero

@@ -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

+ 26
- 0
client/Dockerfile Ver fichero

@@ -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;\""]

+ 13
- 0
client/conf/conf.d/default.conf Ver fichero

@@ -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;
}
}

+ 24
- 0
client/conf/conf.d/gzip.conf Ver fichero

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

+ 17873
- 0
client/package-lock.json
La diferencia del archivo ha sido suprimido porque es demasiado grande
Ver fichero


+ 47
- 0
client/package.json Ver fichero

@@ -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"
}
}

BIN
client/public/favicon.ico Ver fichero

Antes Después

+ 45
- 0
client/public/index.html Ver fichero

@@ -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>

BIN
client/public/logo192.png Ver fichero

Antes Después
Anchura: 192  |  Altura: 192  |  Tamaño: 5.2KB

BIN
client/public/logo512.png Ver fichero

Antes Después
Anchura: 512  |  Altura: 512  |  Tamaño: 9.4KB

+ 25
- 0
client/public/manifest.json Ver fichero

@@ -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"
}

+ 3
- 0
client/public/robots.txt Ver fichero

@@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

+ 1
- 0
client/scripts/docker-build.sh Ver fichero

@@ -0,0 +1 @@
docker build . -t photos-client

+ 9
- 0
client/scripts/docker-run.sh Ver fichero

@@ -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

+ 32
- 0
client/scripts/env.sh Ver fichero

@@ -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

+ 53
- 0
client/src/components/Loader.js Ver fichero

@@ -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
client/src/components/loader.scss Ver fichero


+ 128
- 0
client/src/components/photo-viewer/photo-viewer.js Ver fichero

@@ -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}
</>
}

+ 74
- 0
client/src/components/photo-viewer/photo-viewer.scss Ver fichero

@@ -0,0 +1,74 @@

#photoViewer {
z-index: 999;
position: fixed;
left: 0;
top: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.95);
display: flex;
align-items: center;
justify-content: stretch;

#photoSlider {
position: relative;
display: flex;
align-items: center;
overflow: hidden;
flex-shrink: 1;
max-height: 90vh;

.photo {
position: relative;

img {
margin: 0 auto;
max-height: 90vh;
max-width: calc(100vw - 300px);
@media (max-width: 768px) {
max-width: calc(100vw - 150px);
}
}

&.photo--placeholder {
position: absolute;
}

&.photo--invisible {
visibility: hidden;
}
}
}

.goto-btn {
cursor: pointer;
flex: 1;

&:hover {
svg {
transform: scale(1.2);
opacity: 0.5;
}
}

svg {
color: white;
opacity: 0.15;
width: 75px;
height: 75px;

@media (max-width: 768px) {
width: 75px;
height: 75px;
}
}

&.goto-btn--previous {
text-align: right;
}

&.goto-btn--next {
}
}
}

+ 20
- 0
client/src/hooks/outside-click-alerter.js Ver fichero

@@ -0,0 +1,20 @@
import {useEffect} from "react";

/**
* Hook that alerts clicks outside of the passed ref
*/
export function useOutsideAlerter(ref, handler) {
useEffect(() => {
function handleClickOutside(event) {
if (ref.current && !ref.current.contains(event.target)) {
handler(event);
}
}
// Bind the event listener
document.addEventListener("mousedown", handleClickOutside);
return () => {
// Unbind the event listener on clean up
document.removeEventListener("mousedown", handleClickOutside);
};
}, [ref, handler]);
}

+ 32
- 0
client/src/index.css Ver fichero

@@ -0,0 +1,32 @@
body {
margin: 0;
font-size: 16pt;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}

#root {
background: #fff;
min-height: 100vh;
font-size: 1rem;
display: flex;
flex-direction: column;
}

*,
*::before,
*::after {
box-sizing: border-box;
}

input {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
}

+ 45
- 0
client/src/index.js Ver fichero

@@ -0,0 +1,45 @@
import React, {useLayoutEffect} from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import {BrowserRouter, Navigate, Route, Routes, useLocation} from "react-router-dom";

import '@fontsource/roboto/300.css';
import '@fontsource/roboto/400.css';
import '@fontsource/roboto/500.css';
import '@fontsource/roboto/700.css';
import PublicLayout from "./public/layout";
import Portfolio from "./public/pages/portfolio";
import AlbumPage from "./public/pages/album";
import AccessDenied from "./public/pages/access-denied";
import {HomePage} from "./public/pages/home-page";
import {CurrentInstanceProvider} from "./services/current-instance/current-instance-context";


const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<BrowserRouter>
<CurrentInstanceProvider>
<ScrollToTop>
<Routes>
<Route index element={<HomePage/>}/>
<Route path="/portfolio" element={<PublicLayout/>}>
<Route index element={<Portfolio/>}/>
<Route path=":albumId" element={<AlbumPage/>}/>
<Route path="401" element={<AccessDenied/>}/>
<Route path="*" element={<Navigate to="/"/>}/>
</Route>
</Routes>
</ScrollToTop>
</CurrentInstanceProvider>
</BrowserRouter>
</React.StrictMode>
);

function ScrollToTop({children}) {
const location = useLocation();
useLayoutEffect(() => {
document.documentElement.scrollTo(0, 0);
}, [location.pathname]);
return children
}

+ 16
- 0
client/src/logo.js Ver fichero

@@ -0,0 +1,16 @@
export function Logo() {

return <svg width="50px" height="40px" fill="currentcolor" viewBox="0 0 49 40">
<g id="surface1">
<path d="M 15.554688 0.195312 C 15.027344 0.40625 14.472656 0.863281 14.191406 1.3125 C 14.019531 1.585938 13.792969 2.351562 13.792969 2.640625 C 13.792969 2.695312 17.792969 2.730469 24.507812 2.730469 L 35.222656 2.730469 L 35.222656 2.542969 C 35.222656 2.210938 35.023438 1.539062 34.824219 1.222656 C 34.554688 0.785156 34.078125 0.40625 33.515625 0.183594 C 33.046875 0 32.96875 0 24.527344 0.0078125 C 16.144531 0.0078125 15.996094 0.0195312 15.554688 0.195312 Z M 15.554688 0.195312 "/>
<path d="M 7.289062 3.707031 C 4.960938 3.75 4.683594 3.769531 4.25 3.933594 C 3.390625 4.269531 3.113281 4.402344 2.664062 4.726562 C 1.441406 5.582031 0.695312 6.65625 0.261719 8.160156 C 0.113281 8.679688 0.09375 9.445312 0.0859375 21.761719 L 0.0703125 34.816406 L 0.261719 35.429688 C 0.605469 36.539062 1.015625 37.210938 1.867188 38.070312 C 2.6875 38.917969 3.226562 39.261719 4.363281 39.683594 L 4.988281 39.910156 L 9.542969 39.945312 C 12.050781 39.957031 14.367188 39.9375 14.6875 39.902344 L 15.277344 39.832031 L 14.601562 39.339844 C 13.371094 38.433594 12.023438 37.085938 11.253906 35.976562 L 10.898438 35.476562 L 6.019531 35.476562 L 5.578125 35.253906 C 5.101562 35.007812 4.773438 34.640625 4.617188 34.164062 C 4.46875 33.730469 4.46875 9.894531 4.617188 9.410156 C 4.738281 8.980469 5.195312 8.433594 5.585938 8.238281 C 5.855469 8.105469 7.46875 8.097656 24.5 8.097656 C 39.996094 8.097656 43.171875 8.117188 43.402344 8.222656 C 43.828125 8.398438 44.210938 8.792969 44.417969 9.269531 L 44.601562 9.691406 L 44.539062 21.902344 C 44.488281 33.496094 44.472656 34.128906 44.324219 34.4375 C 44.125 34.839844 43.605469 35.28125 43.1875 35.394531 C 42.96875 35.449219 39.371094 35.476562 32.820312 35.457031 C 23.503906 35.429688 22.738281 35.421875 22.339844 35.273438 C 22.105469 35.195312 21.65625 35.035156 21.34375 34.929688 C 20.066406 34.507812 18.773438 33.660156 17.664062 32.535156 C 16.996094 31.855469 16.136719 30.757812 16.136719 30.582031 C 16.136719 30.554688 16.066406 30.421875 15.972656 30.300781 C 15.746094 29.964844 15.320312 28.847656 15.085938 27.984375 C 14.921875 27.339844 14.894531 27.042969 14.894531 25.75 C 14.894531 24.628906 14.9375 24.136719 15.042969 23.8125 C 15.121094 23.566406 15.183594 23.300781 15.183594 23.214844 C 15.183594 22.957031 15.789062 21.46875 16.164062 20.820312 C 17.757812 18.011719 20.371094 16.285156 23.570312 15.933594 C 26.972656 15.554688 30.652344 17.296875 32.542969 20.195312 C 33.097656 21.046875 33.660156 22.296875 33.886719 23.167969 C 34.070312 23.882812 34.09375 24.171875 34.09375 25.597656 C 34.09375 27.492188 33.949219 28.34375 33.390625 29.570312 C 33.019531 30.371094 32.28125 31.550781 31.734375 32.175781 L 31.386719 32.570312 L 34.136719 32.554688 L 36.878906 32.527344 L 37.046875 32.21875 C 37.816406 30.765625 38.363281 28.792969 38.527344 26.867188 C 38.804688 23.75 38.058594 20.652344 36.402344 18 C 35.007812 15.757812 33.160156 14.074219 30.753906 12.871094 C 27.753906 11.355469 23.632812 11.03125 20.386719 12.050781 C 17.332031 13 14.601562 15.054688 12.855469 17.695312 C 11.851562 19.226562 11.148438 20.949219 10.707031 22.976562 C 10.472656 24.058594 10.472656 27.351562 10.707031 28.433594 C 11.582031 32.457031 13.492188 35.386719 16.640625 37.527344 C 17.984375 38.441406 19.617188 39.144531 21.507812 39.628906 C 22.253906 39.824219 22.296875 39.824219 33.203125 39.851562 L 44.148438 39.886719 L 44.773438 39.65625 C 46.570312 39.023438 47.847656 37.800781 48.53125 36.054688 C 49.042969 34.753906 49.027344 35.441406 48.964844 21.523438 C 48.929688 13.566406 48.878906 8.625 48.828125 8.40625 C 48.226562 5.933594 46.285156 4.09375 43.855469 3.6875 C 43.414062 3.617188 38.816406 3.601562 26.546875 3.617188 C 17.359375 3.636719 8.691406 3.671875 7.289062 3.707031 Z M 7.289062 3.707031 "/>
<path d="M 6.707031 10.007812 C 6.628906 10.035156 6.59375 10.265625 6.59375 10.703125 L 6.59375 11.355469 L 13.621094 11.355469 L 13.621094 10.703125 C 13.621094 10.210938 13.585938 10.042969 13.492188 10.007812 C 13.335938 9.9375 6.855469 9.9375 6.707031 10.007812 Z M 6.707031 10.007812 "/>
<path d="M 22.902344 17.121094 C 20.65625 17.613281 18.703125 18.890625 17.523438 20.660156 C 17.132812 21.242188 17.109375 21.320312 17.28125 21.257812 C 17.394531 21.222656 18.425781 21.179688 19.582031 21.152344 C 21.246094 21.117188 21.90625 21.136719 22.789062 21.257812 C 24.039062 21.425781 25.339844 21.714844 26.242188 22.042969 C 26.574219 22.164062 26.859375 22.246094 26.878906 22.226562 C 26.988281 22.113281 25.792969 19.929688 24.570312 18.027344 C 23.824219 16.867188 23.902344 16.910156 22.902344 17.121094 Z M 22.902344 17.121094 "/>
<path d="M 24.863281 17.21875 C 26.226562 19.277344 26.929688 20.535156 27.5625 22.015625 C 28.089844 23.25 28.324219 24.03125 28.691406 25.738281 L 28.757812 26.082031 L 29.296875 25.253906 C 30.097656 24.003906 31.667969 20.96875 31.667969 20.660156 C 31.667969 20.449219 30.234375 18.96875 29.609375 18.546875 C 28.292969 17.648438 26.816406 17.09375 25.472656 17.015625 L 24.691406 16.964844 Z M 24.863281 17.21875 "/>
<path d="M 31.96875 21.671875 C 31.855469 22.035156 30.796875 24.136719 30.261719 25.0625 C 29.480469 26.390625 28.714844 27.386719 27.546875 28.582031 C 26.652344 29.507812 26.519531 29.675781 26.667969 29.71875 C 26.945312 29.804688 29.636719 29.78125 30.625 29.683594 C 31.128906 29.628906 31.753906 29.578125 32.011719 29.558594 L 32.492188 29.542969 L 32.644531 29.144531 C 33.082031 28.027344 33.175781 27.472656 33.175781 25.925781 C 33.183594 24.613281 33.160156 24.339844 32.976562 23.679688 C 32.777344 22.929688 32.394531 22 32.160156 21.644531 L 32.03125 21.460938 Z M 31.96875 21.671875 "/>
<path d="M 18.261719 22.007812 C 17.742188 22.042969 17.144531 22.078125 16.945312 22.085938 L 16.585938 22.09375 L 16.363281 22.683594 C 15.484375 25.042969 15.609375 27.570312 16.710938 29.78125 C 16.855469 30.070312 16.960938 30.183594 16.988281 30.105469 C 17.046875 29.902344 17.976562 28.027344 18.304688 27.421875 C 19.214844 25.765625 20.335938 24.277344 21.636719 23 L 22.617188 22.035156 L 22.042969 21.972656 C 21.359375 21.910156 19.570312 21.929688 18.261719 22.007812 Z M 18.261719 22.007812 "/>
<path d="M 20.292969 25.667969 C 20.128906 25.855469 19.171875 27.351562 19.171875 27.410156 C 19.171875 27.4375 19.050781 27.667969 18.902344 27.921875 C 18.757812 28.179688 18.359375 28.933594 18.027344 29.605469 C 17.308594 31.046875 17.289062 30.90625 18.347656 32.007812 C 19.796875 33.511719 21.6875 34.445312 23.546875 34.570312 L 24.40625 34.621094 L 23.972656 33.96875 C 22.566406 31.847656 21.523438 29.78125 20.996094 28.035156 C 20.832031 27.492188 20.5 26.082031 20.457031 25.714844 C 20.429688 25.511719 20.421875 25.511719 20.292969 25.667969 Z M 20.292969 25.667969 "/>
<path d="M 22.46875 29.675781 C 22.574219 29.886719 22.722656 30.203125 22.800781 30.371094 C 23.46875 31.75 25.273438 34.59375 25.488281 34.59375 C 25.878906 34.59375 26.800781 34.394531 27.492188 34.15625 C 29.332031 33.53125 30.9375 32.261719 31.933594 30.632812 L 32.074219 30.414062 L 29.332031 30.414062 C 26.21875 30.414062 25.359375 30.308594 23.511719 29.699219 C 23.292969 29.621094 23.007812 29.535156 22.859375 29.5 C 22.722656 29.453125 22.53125 29.394531 22.445312 29.355469 C 22.296875 29.296875 22.296875 29.3125 22.46875 29.675781 Z M 22.46875 29.675781 "/>
</g>
</svg>;
}

+ 128
- 0
client/src/mixins.scss Ver fichero

@@ -0,0 +1,128 @@
@import "./variables";

@keyframes sb_slide_negative_left_to_right {
from {
transform: translateX(-$sidebar_width_1920);
@media(max-width: 1920px) {
transform: translateX(-$sidebar_width_1024_1920);
}
@media(max-width: 1024px) {
transform: translateX(-$sidebar_width_768_1024);
}
@media(max-width: 768px) {
transform: translateX(-$sidebar_width_480_768);
}
}

to {
transform: translateX(0);
}
}

@mixin sb_slide_negative_left_to_right() {
transition: 0.6s;
animation: ease-out;
animation-name: sb_slide_negative_left_to_right;
transform: translateX(0);
}


@keyframes sb_slide_right_to_negative_left {
from {
transform: translateX(0);
}

to {
transform: translateX(-$sidebar_width_1920);
@media(max-width: 1920px) {
transform: translateX(-$sidebar_width_1024_1920);
}
@media(max-width: 1024px) {
transform: translateX(-$sidebar_width_768_1024);
}
@media(max-width: 768px) {
transform: translateX(-$sidebar_width_480_768);
}
}
}

@mixin sb_slide_right_to_negative_left() {
transition: 0.6s;
animation: ease-out;
animation-name: sb_slide_right_to_negative_left;

transform: translateX(-1 * $sidebar_width_1920);
@media(max-width: 1920px) {
transform: translateX(-1 * $sidebar_width_1024_1920);
}
@media(max-width: 1024px) {
transform: translateX(-1 * $sidebar_width_768_1024);
}
@media(max-width: 768px) {
transform: translateX(-1 * $sidebar_width_480_768);
}
}

@keyframes sb_slide_left_to_right {
from {
transform: translateX(0);
}

to {
transform: translateX($sidebar_width_1920);
@media(max-width: 1920px) {
transform: translateX($sidebar_width_1024_1920);
}
@media(max-width: 1024px) {
transform: translateX($sidebar_width_768_1024);
}
@media(max-width: 768px) {
transform: translateX($sidebar_width_480_768);
}
}
}

@mixin sb_slide_left_to_right() {
transition: 0.6s;
animation: ease-out;
animation-name: sb_slide_left_to_right;

transform: translateX($sidebar_width_1920);
@media(max-width: 1920px) {
transform: translateX($sidebar_width_1024_1920);
}
@media(max-width: 1024px) {
transform: translateX($sidebar_width_768_1024);
}
@media(max-width: 768px) {
transform: translateX($sidebar_width_480_768);
}
}


@keyframes sb_slide_right_to_left {
from {
transform: translateX($sidebar_width_1920);
@media(max-width: 1920px) {
transform: translateX($sidebar_width_1024_1920);
}
@media(max-width: 1024px) {
transform: translateX($sidebar_width_768_1024);
}
@media(max-width: 768px) {
transform: translateX($sidebar_width_480_768);
}
}

to {
transform: translateX(0);
}
}

@mixin sb_slide_right_to_left() {
transition: 0.6s;
animation: ease-out;
animation-name: sb_slide_right_to_left;

transform: translateX(0);
}

+ 34
- 0
client/src/models/album.js Ver fichero

@@ -0,0 +1,34 @@
import {Photo} from "./photo.js";

export class Album {
/**
* @param {Album} [album]
*/
constructor(album) {
this._id = album?._id;
this.albumId = album?.albumId;
this.name = album?.name;
this.description = album?.description;
this.sort = album?.sort;
this.sortOrder = album?.sortOrder;
this.photos = album?.photos?.map(photo => new Photo(photo)) || [];
this.coverPhoto = this.photos.length > 0 ? this.photos[0] : null;
}

/** @type {string} **/
id;
/** @type {string} **/
albumId;
/** @type {string} **/
name;
/** @type {string} **/
description;
/** @type {"manual"|"auto"} **/
sort;
/** @type {number} **/
sortOrder;
/** @type {Photo[]} **/
photos;
/** @type {Photo} **/
coverPhoto;
}

+ 11
- 0
client/src/models/instance.js Ver fichero

@@ -0,0 +1,11 @@
import {Photo} from "./photo";

export class Instance {
constructor(instance) {
this._id = instance?._id;
this.instanceId = instance?.instanceId;
this.title = instance?.title;
this.subtitle = instance?.subtitle;
this.coverPhoto = instance?.coverPhoto ? new Photo(instance.coverPhoto) : null;
}
}

+ 29
- 0
client/src/models/photo.js Ver fichero

@@ -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;
}

+ 5
- 0
client/src/public/context/pageTransition.js Ver fichero

@@ -0,0 +1,5 @@
import {createContext} from "react";

export const PageTransitionContext = createContext({
mounted: false
})

+ 57
- 0
client/src/public/layout.js Ver fichero

@@ -0,0 +1,57 @@
import * as React from "react";

import "./public.scss"
import {useLayoutEffect, useState} from "react";
import {NavLink, Outlet, useLocation} from "react-router-dom";
import {KeyboardArrowRightRounded, Menu} from "@mui/icons-material";
import {PageTransitionContext} from "./context/pageTransition";
import {PhotoViewer} from "../components/photo-viewer/photo-viewer";
import Loader from "../components/Loader";
import {useCurrentInstance} from "../services/current-instance/use-current-instance";

export default function PublicLayout() {
const {isLoading, apiError, albums} = useCurrentInstance();

const [mounted, setMounted] = useState(false);
const pageTransition = {
mounted, setMounted
}
// const fetchedRef = useRef(false);
const [sidebarIsOpen, setSidebarIsOpen] = useState(false);

const location = useLocation();
useLayoutEffect(() => {
setMounted(false);
setTimeout(() => {
setMounted(true);
}, 10);
}, [location.pathname]);

return <PageTransitionContext.Provider value={pageTransition}>
<PhotoViewer />
<div id="publicPage">
<main id="page">
<Loader loading={isLoading} />
{!isLoading && <>
<div id="sidebarButton" onClick={() => setSidebarIsOpen(!sidebarIsOpen)}>
<Menu />
</div>
<div id="sidebar" className={`${(sidebarIsOpen ? "sidebar-open" : "sidebar-closed")}`}>
<div className="sidebar">
<NavLink onClick={() => setSidebarIsOpen(false)} to="/" className="home">Home</NavLink>
{albums.map(a => <NavLink onClick={() => setSidebarIsOpen(false)} key={a.albumId} to={a.albumId}><KeyboardArrowRightRounded /> {a.name}</NavLink>)}
</div>
</div>
<div id="sidebar-mobile" className={`sidebar ${(sidebarIsOpen ? "sidebar-open" : "sidebar-closed")}`}>
<NavLink onClick={() => setSidebarIsOpen(false)} to="/" className="home">Home</NavLink>
{albums.map(a => <NavLink onClick={() => setSidebarIsOpen(false)} key={a.albumId} to={a.albumId}><KeyboardArrowRightRounded /> {a.name}</NavLink>)}
</div>
<div id={"page-content"} className={`${(sidebarIsOpen ? "sidebar-open" : "sidebar-closed")} ${(pageTransition.mounted ? "mounted" : "unmounted")}`}>
{apiError && <div className="error-box">{apiError}</div>}
{!apiError && <Outlet />}
</div>
</>}
</main>
</div>
</PageTransitionContext.Provider>
}

+ 7
- 0
client/src/public/pages/access-denied.js Ver fichero

@@ -0,0 +1,7 @@
function AccessDenied() {
return (
<h1>Access denied!</h1>
)
}

export default AccessDenied;

+ 53
- 0
client/src/public/pages/album.js Ver fichero

@@ -0,0 +1,53 @@
import * as React from "react";
import {useEffect} from "react";
import {useParams} from "react-router-dom";
import {ZoomOutMapSharp} from "@mui/icons-material";
import {useCurrentInstance} from "../../services/current-instance/use-current-instance";

export default function AlbumPage() {
const {
albums,
selectedAlbum,
setSelectedAlbum,
setSelectedPhotoIndex
} = useCurrentInstance();
const {albumId} = useParams();

useEffect(() => {
const ab = albums.filter(a => a.albumId === albumId)[0];
if (selectedAlbum !== ab) {
setSelectedAlbum(ab);
}
}, [albums, selectedAlbum, albumId, setSelectedAlbum]);

return selectedAlbum && <div id="page-album" className="page-outlet">
<div className="page-title">
<div className="title">
{selectedAlbum.name}
</div>
{selectedAlbum.description &&
<div className="extra-info"
dangerouslySetInnerHTML={{__html: selectedAlbum.description.replace(/\n/g, "<br />")}}>
</div>}
</div>
{selectedAlbum.photos && <div id="photo-list" className="photo-grid-layout">
{selectedAlbum.photos.map((photo, idx) =>
<div className="photo grid-cell" key={photo.photoId}
onClick={() => setSelectedPhotoIndex(idx)}>
<div className="thumbnail photo-thumbnail">
<img src={`${photo.url}?width=800&height=800&fit=cover`}
alt={photo.name}/>
<div className="zoom-button">
<ZoomOutMapSharp/>
</div>
</div>
<div className="details">
<div className="name">
{photo.name}
</div>
</div>
</div>
)}
</div>}
</div>
}

+ 23
- 0
client/src/public/pages/home-page.js Ver fichero

@@ -0,0 +1,23 @@
import {useCurrentInstance} from "../../services/current-instance/use-current-instance";

import "./home-page.scss";
import {NavLink} from "react-router-dom";

export function HomePage() {
const {instance} = useCurrentInstance();

return instance && <div id="home">
<div className="cover-photo">
<img alt={instance.coverPhoto?.name} src={instance.coverPhoto?.url}/>
</div>
<div className="instance-details">
<div className="box">
<h1>{instance.title}</h1>
<h2>{instance.subtitle}</h2>
<NavLink to="/portfolio" className="goto-website-link">
GO TO WEBSITE
</NavLink>
</div>
</div>
</div>;
}

+ 99
- 0
client/src/public/pages/home-page.scss Ver fichero

@@ -0,0 +1,99 @@
#home {
height: 100vh;
width: 100vw;
display: grid;
grid-template-columns: 1fr 1fr;

@media(max-width: 1200px) {
grid-template-columns: 1fr;
grid-template-rows: 1fr 1fr;
}

.cover-photo {
overflow: hidden;
display: flex;
justify-content: center;
align-items: center;

img {
width: 100%;
height: 100%;
object-position: 50% 50%;
object-fit: cover;
}
}

.instance-details {
display: flex;
justify-content: center;
flex-direction: column;

.box {
position: static !important;
transform: none;
margin-left: auto;
margin-right: auto;
padding: 60px;
max-width: 620px;
text-align: center;

h1 {
text-align: center;
display: inline-block;
font-size: 37px;
line-height: 1em;
margin: 0;
margin-bottom: 22px;
//font-family: proxima-nova;
font-weight: 700;
font-style: normal;
text-transform: uppercase;
letter-spacing: .1em;
color: #333;

@media(max-width: 600px) {
font-size: 24px;
}
}

h2 {
//font-family: minion-pro;
font-size: 19px;
font-weight: 400;
font-style: normal;
text-transform: uppercase;
letter-spacing: .28em;
line-height: 1.7em;
color: #333;
margin: 0;
margin-bottom: 36px;
}

.goto-website-link {
text-rendering: optimizeLegibility;
white-space: nowrap;
line-height: 1em;
//font-family: proxima-nova;
font-weight: 500;
font-style: normal;
font-size: 14px;
text-transform: uppercase;
letter-spacing: 0;
text-align: center;
padding: 1em calc(1.44em - 0em) 1em 1.44em;
display: inline-block;
text-decoration: none;
background-color: transparent;
color: #333;
border: 2px solid #333;
transition: background-color 170ms ease-in-out, color 170ms ease-in-out;
//transition: color 170ms ease-in-out, border-color 170ms ease-in-out;

&:hover {
background-color: black;
color: white;
}
}
}
}
}

+ 33
- 0
client/src/public/pages/portfolio.js Ver fichero

@@ -0,0 +1,33 @@
import {Link} from "react-router-dom";
import * as React from "react";
import {ImageSearch} from "@mui/icons-material";

import {useCurrentInstance} from "../../services/current-instance/use-current-instance";

function Portfolio() {
const {instance, albums} = useCurrentInstance();

return instance && <div id="page-portfolio" className="page-outlet">
<div className="page-title">
<div className="title">
{instance.title}
</div>
</div>
{albums?.length > 0 && <div id="album-list" className="photo-grid-layout">
{albums.map((album) =>
<div className="album grid-cell" key={album.albumId}>
<Link to={album.albumId} className={`thumbnail ${(album.coverPhoto ? "" : "thumbnail--no-img")}`}>
{album.coverPhoto && <img src={`${album.coverPhoto.url}?width=800&height=800&fit=cover`} alt={album.coverPhoto.name} />}
{!album.coverPhoto && <ImageSearch />}
</Link>
<div className="details">
<div className="name">
{album.name}
</div>
</div>
</div>
)}
</div>}
</div>;
}
export default Portfolio;

+ 308
- 0
client/src/public/public.scss Ver fichero

@@ -0,0 +1,308 @@
@import "../variables.scss";
@import "../mixins.scss";
html:has(#photoViewer) {
overflow: hidden;
}
#publicPage {
margin: 0 auto;
width: 100%;
flex: 1;
display: flex;
flex-direction: row;
padding: 15px;

#page {
width: 100%;
overflow: hidden;
position: relative;
flex: 1;

@media(max-width: $sidebar_mobile_breakpoint) {
flex-direction: column;
}

#sidebarButton {
cursor: pointer;
z-index: 999;
position: relative;
width: 30px;
height: 30px;

> svg {
width: 30px;
height: 30px;
}
}

#sidebar {
padding-left: 15px;
padding-top: 15px;
height: 100vh;
overflow: hidden;
display: flex;
flex-direction: column;
position: fixed;

@media(max-width: $sidebar_mobile_breakpoint) {
display: none;
}

&.sidebar-open {
@include sb_slide_negative_left_to_right();
}

&.sidebar-closed {
@include sb_slide_right_to_negative_left();
}
}

#sidebar-mobile {
padding-left: 50px;
background: white;
width: 100%;
overflow-y: auto;
display: none;
flex-direction: column;
height: $sidebar_mobile_height;
border-bottom: 1px solid #000;

@media(max-width: $sidebar_mobile_breakpoint) {
display: flex;
}

@keyframes slideDown {
from {
margin-top: calc(-1 * $sidebar_mobile_height - 50px);
}
to {
margin-top: 0;
}
}

&.sidebar-open {
margin-top: 0;
transition: 0.5s;
animation: ease-in;
animation-name: slideDown;
}

@keyframes slideUp {
from {
margin-top: 0;
}
to {
margin-top: calc(-1 * $sidebar_mobile_height - 50px);
}
}

&.sidebar-closed {
margin-top: calc(-1 * $sidebar_mobile_height - 50px);
transition: 0.5s;
animation: ease-in;
animation-name: slideUp
}
}

.sidebar {
> a {
width: 100%;
font-weight: 400;
line-height: 1.4em;
color: #000;
text-decoration: none;
display: flex;
align-items: flex-start;
margin-bottom: 5px;

svg {
height: 19px;
width: 24px;
}

&:hover,
&.active {
font-weight: 600;
}

&:visited,
&:hover {
color: #000;
}
}
}

#page-content {
padding-top: 20px;
display: flex;
width: 100%;

.page-outlet {
transform: translateX(100vw);

@media (max-width: $sidebar_mobile_breakpoint) {
transform: translateX(0);
margin-top: 50px;
}
}

&.mounted {
.page-outlet {
@include sb_slide_right_to_left();
}
}

&.sidebar-closed {
@include sb_slide_right_to_left();
@media (max-width: $sidebar_mobile_breakpoint) {
animation: none;
transform: translateX(0);
}
}

&.sidebar-open {
@include sb_slide_left_to_right();
@media (max-width: $sidebar_mobile_breakpoint) {
animation: none;
transform: translateX(0);
}
}

.page-title {
margin-bottom: 15px;

.title {
font-size: 2.4rem;
margin-bottom: 15px;
}

.extra-info {
font-size: 1.2rem;
color: #333;
font-style: italic;
}
}

.photo-grid-layout {
width: 100%;
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-auto-rows: 1fr;
row-gap: calc(4em + 30px);
column-gap: 30px;
margin-bottom: 4em;

//@media (max-width: 1920px) {
// grid-template-columns: repeat(4, 1fr);
//}
//@media (max-width: 1280px) {
// grid-template-columns: repeat(4, 1fr);
//}
@media (max-width: 768px) {
grid-template-columns: repeat(2, 1fr);
}

.grid-cell {
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
position: relative;
cursor: pointer;

&:hover {
//transform: scale(1.05);
}

.thumbnail {
width: 100%;
position: relative;

img {
width: 100%;
}

.zoom-button {
position: absolute;
left: 0;
top: 0;
height: 100%;
width: 100%;
display: flex;
align-items: center;
background-color:rgba(0,0,0, 0);
transition: background-color 0.6s;

&:hover {
background-color:rgba(0,0,0, 0.35);

svg {
transform: scale(1.4);
color: rgba(255, 255, 255, 0.7);
}
}

svg {
margin: 0 auto;
width: 20%;
height: 20%;
color: rgba(255, 255, 255, 0.2);
transition: transform 0.6s, color 0.6s;
}
}

&.thumbnail--no-img {
width: 100%;
text-align: center;
height: 100%;
padding-top: calc(50% - 75px);

> svg {
width: 75px;
height: 75px;
color: $accentColor;
}
}
}

.details {
position: absolute;
bottom: calc(-4em + 5px);
display: flex;
flex-direction: column;
height: 4em;
width: 80%;
margin: 0 auto;
text-align: center;

.name {
margin-top: 15px;
}
}
}
}
}

.error-box {
padding: 0 30px;
height: 60px;
line-height: 60px;
background: lighten(red, 46);
border: 2px solid crimson;
border-radius: $borderRadius;
color: crimson;
font-size: 1.2rem;
margin: 15px auto 0;
}

#page-loader {
position: absolute;
padding-left: 50%;
padding-top: 120px;

> span > span {
background-color: $accentColor !important;
}
}
}
}


+ 34
- 0
client/src/public/services/public-api.js Ver fichero

@@ -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;
}
}

+ 57
- 0
client/src/services/current-instance/current-instance-context.js Ver fichero

@@ -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>;
};

+ 17
- 0
client/src/services/current-instance/use-current-instance.js Ver fichero

@@ -0,0 +1,17 @@
import {CurrentInstanceContext} from "./current-instance-context";
import {useContext} from "react";

/**
* @return {InstancesContext}
*/
export const useCurrentInstance = () => {
const context = useContext(CurrentInstanceContext);

if (!context) {
throw new Error(
'Current instance context is undefined, please verify you are calling useCurrentInstance() as child of a <CurrentInstanceProvider> component.',
);
}

return context;
};

+ 23
- 0
client/src/utils/animation.js Ver fichero

@@ -0,0 +1,23 @@
export const createAnimation = (loaderName, frames, suffix) => {
const animationName = `react-spinners-${loaderName}-${suffix}`;

if (typeof window == "undefined" || !window.document) {
return animationName;
}

const styleEl = document.createElement("style");
document.head.appendChild(styleEl);
const styleSheet = styleEl.sheet;

const keyFrames = `
@keyframes ${animationName} {
${frames}
}
`;

if (styleSheet) {
styleSheet.insertRule(keyFrames, 0);
}

return animationName;
};

+ 16
- 0
client/src/variables.scss Ver fichero

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

+ 1215
- 0
package-lock.json
La diferencia del archivo ha sido suprimido porque es demasiado grande
Ver fichero


+ 19
- 0
package.json Ver fichero

@@ -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"
}
}

+ 2
- 0
server/.gitignore Ver fichero

@@ -0,0 +1,2 @@
/node_modules
/dist

+ 10
- 0
server/Dockerfile Ver fichero

@@ -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"]

+ 3553
- 0
server/package-lock.json
La diferencia del archivo ha sido suprimido porque es demasiado grande
Ver fichero


+ 34
- 0
server/package.json Ver fichero

@@ -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"
}
}

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio

Cargando…
Cancelar
Guardar