| @@ -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 | |||||
| @@ -0,0 +1,2 @@ | |||||
| .idea | |||||
| node_modules | |||||
| @@ -0,0 +1,41 @@ | |||||
| # Novox Photos manager | |||||
| ## Client - Photos react app | |||||
| ### App structure | |||||
| - React application with a public facing website (`./public`) and an administration panel (`./private`) | |||||
| - React router is split into `<PublicRoutes />` and `<PrivateRoutes />` | |||||
| - `<PrivateRoutes />` is wrapped in the `<Keycloak>` context provider | |||||
| - The `<Keyclock>` context provider is created using the `AuthProvider` from the | |||||
| [`oidc-react`](https://github.com/bjerkio/oidc-react) package | |||||
| - AutoSignin is explicitly set to `false` to prevent the website from always trying to log in (even if not trying | |||||
| to access the admin section) | |||||
| - The `<AdminLayout>` component handles the authentication only rendering the `<Outlet>` when the user is logged in | |||||
| and has sufficient permissions (role based) | |||||
| - We can access the user info using the `useAuth` hook, only possible in React components or hooks | |||||
| - Our `privateApi` service is provided using the `usePrivateApi` hook, it makes use of the `useAuth` hook to set | |||||
| the authorization header | |||||
| - The url for the private api is set using a "environment variable" (see build process) | |||||
| ### Build process | |||||
| - The build process is quite simple and uses `react-scripts build` | |||||
| - "Environment variables" can be set in the `.env` file. Before starting the application run `./scripts/env.sh` to | |||||
| create an `env-config.js` file in the`./public` folder which is then loaded using a `<script>` tag in index.html | |||||
| - In our docker setup, we can pass the environment variables in our docker-compose. When starting the container | |||||
| the `env.sh` script is executed before starting the application | |||||
| ## Server - Photos node api | |||||
| ### App structure | |||||
| - We use [Koa](https://github.com/koajs/koa) to setup our web api | |||||
| - Our api is split into a `/api/public` and `/api/private` routes (see the 2 routers) | |||||
| - `Router` and `HttpEndpoint` types can be used to add new endpoints | |||||
| - Following middleware is provided: | |||||
| - JWT token validation | |||||
| - Request/response logging | |||||
| - CORS | |||||
| - Error handling | |||||
| - `@koa/multer` to handle file uploads using `multipart/form-data` | |||||
| @@ -0,0 +1 @@ | |||||
| REACT_APP_API_URL=http://localhost:9001 | |||||
| @@ -0,0 +1,27 @@ | |||||
| # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. | |||||
| # dependencies | |||||
| /node_modules | |||||
| /.pnp | |||||
| .pnp.js | |||||
| # testing | |||||
| /coverage | |||||
| # production | |||||
| /build | |||||
| # misc | |||||
| .DS_Store | |||||
| .env.local | |||||
| .env.development.local | |||||
| .env.test.local | |||||
| .env.production.local | |||||
| npm-debug.log* | |||||
| yarn-debug.log* | |||||
| yarn-error.log* | |||||
| # Temporary env files | |||||
| /public/env-config.js | |||||
| env-config.js | |||||
| @@ -0,0 +1,26 @@ | |||||
| FROM nginx:1.15.2-alpine | |||||
| # Nginx config | |||||
| RUN rm -rf /etc/nginx/conf.d | |||||
| COPY ./conf /etc/nginx | |||||
| # Static build | |||||
| COPY ./build /usr/share/nginx/html/ | |||||
| # Default port exposure | |||||
| EXPOSE 80 | |||||
| # Copy .env file and shell script to container | |||||
| WORKDIR /usr/share/nginx/html | |||||
| COPY scripts/env.sh . | |||||
| COPY .env . | |||||
| # Add bash | |||||
| RUN apk add --no-cache bash | |||||
| # Make our shell script executable | |||||
| RUN chmod +x env.sh | |||||
| # Start Nginx server | |||||
| CMD ["/bin/bash", "-c", "/usr/share/nginx/html/env.sh && nginx -g \"daemon off;\""] | |||||
| @@ -0,0 +1,13 @@ | |||||
| server { | |||||
| listen 80; | |||||
| location / { | |||||
| root /usr/share/nginx/html; | |||||
| index index.html index.htm; | |||||
| try_files $uri $uri/ /index.html; | |||||
| expires -1; # Set it to different value depending on your standard requirements | |||||
| } | |||||
| error_page 500 502 503 504 /50x.html; | |||||
| location = /50x.html { | |||||
| root /usr/share/nginx/html; | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,24 @@ | |||||
| gzip on; | |||||
| gzip_http_version 1.0; | |||||
| gzip_comp_level 5; # 1-9 | |||||
| gzip_min_length 256; | |||||
| gzip_proxied any; | |||||
| gzip_vary on; | |||||
| # MIME-types | |||||
| gzip_types | |||||
| application/atom+xml | |||||
| application/javascript | |||||
| application/json | |||||
| application/rss+xml | |||||
| application/vnd.ms-fontobject | |||||
| application/x-font-ttf | |||||
| application/x-web-app-manifest+json | |||||
| application/xhtml+xml | |||||
| application/xml | |||||
| font/opentype | |||||
| image/svg+xml | |||||
| image/x-icon | |||||
| text/css | |||||
| text/plain | |||||
| text/x-component; | |||||
| @@ -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" | |||||
| } | |||||
| } | |||||
| @@ -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> | |||||
| @@ -0,0 +1,25 @@ | |||||
| { | |||||
| "short_name": "React App", | |||||
| "name": "Create React App Sample", | |||||
| "icons": [ | |||||
| { | |||||
| "src": "favicon.ico", | |||||
| "sizes": "64x64 32x32 24x24 16x16", | |||||
| "type": "image/x-icon" | |||||
| }, | |||||
| { | |||||
| "src": "logo192.png", | |||||
| "type": "image/png", | |||||
| "sizes": "192x192" | |||||
| }, | |||||
| { | |||||
| "src": "logo512.png", | |||||
| "type": "image/png", | |||||
| "sizes": "512x512" | |||||
| } | |||||
| ], | |||||
| "start_url": ".", | |||||
| "display": "standalone", | |||||
| "theme_color": "#000000", | |||||
| "background_color": "#ffffff" | |||||
| } | |||||
| @@ -0,0 +1,3 @@ | |||||
| # https://www.robotstxt.org/robotstxt.html | |||||
| User-agent: * | |||||
| Disallow: | |||||
| @@ -0,0 +1 @@ | |||||
| docker build . -t photos-admin-client | |||||
| @@ -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 | |||||
| @@ -0,0 +1,32 @@ | |||||
| #!/bin/bash | |||||
| envConfigFile=${1:-./env-config.js} | |||||
| # Recreate config file | |||||
| rm -rf $envConfigFile | |||||
| touch $envConfigFile | |||||
| # Add assignment | |||||
| echo "window._env_ = {" >> $envConfigFile | |||||
| # Read each line in .env file | |||||
| # Each line represents key=value pairs | |||||
| while read -r line || [[ -n "$line" ]]; | |||||
| do | |||||
| # Split env variables by character `=` | |||||
| if printf '%s\n' "$line" | grep -q -e '='; then | |||||
| varname=$(printf '%s\n' "$line" | sed -e 's/=.*//') | |||||
| varvalue=$(printf '%s\n' "$line" | sed -e 's/^[^=]*=//') | |||||
| fi | |||||
| # Read value of current variable if exists as Environment variable | |||||
| value=$(printf '%s\n' "${!varname}") | |||||
| # Otherwise use value from .env file | |||||
| [[ -z $value ]] && value=${varvalue} | |||||
| # Append configuration property to JS file | |||||
| echo " $varname: \"$value\"," >> $envConfigFile | |||||
| done < .env | |||||
| echo "}" >> $envConfigFile | |||||
| @@ -0,0 +1,242 @@ | |||||
| @import "variables"; | |||||
| #admin-page { | |||||
| margin: 0 auto; | |||||
| width: 100%; | |||||
| max-width: 1280px; | |||||
| flex: 1; | |||||
| display: flex; | |||||
| flex-direction: column; | |||||
| padding: 0 $pageMargin $pageMargin; | |||||
| #page { | |||||
| position: relative; | |||||
| background: #f6f6f6; | |||||
| flex: 1; | |||||
| border: 2px solid $accentColor; | |||||
| border-radius: $borderRadius; | |||||
| padding: $borderRadius; | |||||
| } | |||||
| #page-title { | |||||
| font-size: 1.8rem; | |||||
| line-height: 2em; | |||||
| margin-bottom: 25px; | |||||
| display: flex; | |||||
| align-items: center; | |||||
| justify-content: space-between; | |||||
| #left-part { | |||||
| display: flex; | |||||
| justify-content: flex-start; | |||||
| button { | |||||
| padding: 0; | |||||
| font-size: 2rem; | |||||
| height: 1.8em; | |||||
| svg#navigate-back { | |||||
| width: 1.8em; | |||||
| height: 1.8em; | |||||
| } | |||||
| } | |||||
| .input-element { | |||||
| margin-bottom: 0; | |||||
| margin-left: 15px; | |||||
| } | |||||
| .photos-count { | |||||
| font-size: 0.6em; | |||||
| line-height: 2em; | |||||
| color: #333; | |||||
| font-style: italic; | |||||
| margin-left: 15px; | |||||
| } | |||||
| } | |||||
| .MuiInput-input { | |||||
| font-size: 2rem; | |||||
| } | |||||
| } | |||||
| #page-loader { | |||||
| position: absolute; | |||||
| padding-left: 50%; | |||||
| padding-top: 120px; | |||||
| > span > span { | |||||
| background-color: $accentColor !important; | |||||
| } | |||||
| } | |||||
| .card-layout { | |||||
| padding: 15px; | |||||
| display: flex; | |||||
| flex-direction: row; | |||||
| align-items: flex-start; | |||||
| justify-content: space-between; | |||||
| border: 2px solid $borderColor; | |||||
| border-radius: $borderRadius; | |||||
| position: relative; | |||||
| .cover-photo { | |||||
| position: relative; | |||||
| height: 350px; | |||||
| flex: 0 0 350px; | |||||
| margin-bottom: 3px; | |||||
| cursor: pointer; | |||||
| width: 100%; | |||||
| max-width: 100%; | |||||
| border-radius: $borderRadius; | |||||
| box-shadow: #333 5px 5px 5px; | |||||
| overflow: hidden; | |||||
| img { | |||||
| width: 100%; | |||||
| transform: scale(1); | |||||
| } | |||||
| &:hover { | |||||
| img, | |||||
| svg { | |||||
| transform: scale(1.05); | |||||
| } | |||||
| } | |||||
| .file-name { | |||||
| position: absolute; | |||||
| bottom: 0; | |||||
| background: white; | |||||
| width: 100%; | |||||
| text-align: center; | |||||
| font-size: 0.8em; | |||||
| font-style: italic; | |||||
| line-height: 1.2em; | |||||
| padding: 0 20px; | |||||
| } | |||||
| &.cover-photo--no-img { | |||||
| //padding-left: calc(350px / 2 - 37.5px); | |||||
| display: flex; | |||||
| align-items: center; | |||||
| flex-direction: column; | |||||
| justify-content: center; | |||||
| #page-loader { | |||||
| padding: 9px; | |||||
| } | |||||
| svg.no-photos { | |||||
| width: 75px; | |||||
| height: 75px; | |||||
| color: $accentColor; | |||||
| } | |||||
| } | |||||
| } | |||||
| .card-details { | |||||
| flex: 1 1 auto; | |||||
| margin-left: 25px; | |||||
| margin-top: 25px; | |||||
| @media(max-width: 968px) { | |||||
| margin-left: 0; | |||||
| width: 100%; | |||||
| max-width: 450px; | |||||
| } | |||||
| .input-element { | |||||
| margin-bottom: 25px; | |||||
| .input-error { | |||||
| visibility: hidden; | |||||
| position: absolute; | |||||
| bottom: -1.4em; | |||||
| left: 25px; | |||||
| font-size: 11pt; | |||||
| color: red; | |||||
| font-style: italic; | |||||
| } | |||||
| } | |||||
| } | |||||
| .input-buttons { | |||||
| display: flex; | |||||
| justify-content: space-between; | |||||
| @media(max-width: 968px) { | |||||
| flex-direction: column; | |||||
| } | |||||
| .left-buttons { | |||||
| display: flex; | |||||
| button { | |||||
| margin-right: 15px; | |||||
| } | |||||
| @media(max-width: 968px) { | |||||
| justify-content: space-between; | |||||
| margin-bottom: 15px; | |||||
| button { | |||||
| margin-right: 0; | |||||
| } | |||||
| } | |||||
| } | |||||
| .right-buttons { | |||||
| display: flex; | |||||
| button { | |||||
| margin-left: 15px; | |||||
| } | |||||
| @media(max-width: 968px) { | |||||
| justify-content: space-between; | |||||
| margin-bottom: 15px; | |||||
| button { | |||||
| margin-left: 0; | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| .upload-error { | |||||
| width: 100%; | |||||
| padding: 25px; | |||||
| bottom: 0; | |||||
| text-align: center; | |||||
| position: absolute; | |||||
| font-size: 11pt; | |||||
| color: red; | |||||
| font-style: italic; | |||||
| } | |||||
| .upload-success { | |||||
| width: 100%; | |||||
| padding: 25px; | |||||
| bottom: 0; | |||||
| text-align: center; | |||||
| position: absolute; | |||||
| font-size: 11pt; | |||||
| color: green; | |||||
| font-style: italic; | |||||
| } | |||||
| #photosUpload { | |||||
| display: none; | |||||
| } | |||||
| .error-box { | |||||
| padding: 15px; | |||||
| background: lighten(red, 46); | |||||
| border: 2px solid crimson; | |||||
| border-radius: $borderRadius; | |||||
| color: crimson; | |||||
| font-size: 1rem; | |||||
| margin-bottom: 15px; | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,53 @@ | |||||
| import {createAnimation} from "../utils/animation"; | |||||
| const sync = createAnimation( | |||||
| "SyncLoader", | |||||
| `33% {transform: translateY(10px)} | |||||
| 66% {transform: translateY(-10px)} | |||||
| 100% {transform: translateY(0)}`, | |||||
| "sync" | |||||
| ); | |||||
| function Loader({ | |||||
| loading = false, | |||||
| color = "#000000", | |||||
| speedMultiplier = 1, | |||||
| cssOverride = {}, | |||||
| size = 15, | |||||
| margin = 2, | |||||
| ...additionalprops | |||||
| }) { | |||||
| const wrapper = { | |||||
| display: "inherit", | |||||
| ...cssOverride, | |||||
| }; | |||||
| const style = (i) => { | |||||
| return { | |||||
| backgroundColor: color, | |||||
| width: size, | |||||
| height: size, | |||||
| margin: margin, | |||||
| borderRadius: "100%", | |||||
| display: "inline-block", | |||||
| animation: `${sync} ${0.6 / speedMultiplier}s ${i * 0.07}s infinite ease-in-out`, | |||||
| animationFillMode: "both", | |||||
| }; | |||||
| }; | |||||
| if (!loading) { | |||||
| return null; | |||||
| } | |||||
| return ( | |||||
| <div id="page-loader"> | |||||
| <span style={wrapper} {...additionalprops}> | |||||
| <span style={style(1)}/> | |||||
| <span style={style(2)}/> | |||||
| <span style={style(3)}/> | |||||
| </span> | |||||
| </div> | |||||
| ); | |||||
| } | |||||
| export default Loader; | |||||
| @@ -0,0 +1,44 @@ | |||||
| import {useState} from "react"; | |||||
| import "./drag-and-drop.scss"; | |||||
| import Loader from "../Loader"; | |||||
| import * as React from "react"; | |||||
| function DragAndDrop({className, onDrop, children, isLoading, ...otherAtributes}) { | |||||
| const [dragging, setDragging] = useState(false); | |||||
| const handleDragEnter = (e) => { | |||||
| e.preventDefault(); | |||||
| e.stopPropagation(); | |||||
| setDragging(true); | |||||
| } | |||||
| const handleDragLeave = (e) => { | |||||
| e.preventDefault(); | |||||
| e.stopPropagation(); | |||||
| setDragging(false); | |||||
| } | |||||
| const handleDragStart = (e) => { | |||||
| e.dataTransfer.clearData(); | |||||
| } | |||||
| const handleDrop = (e) => { | |||||
| e.preventDefault(); | |||||
| e.stopPropagation(); | |||||
| setDragging(false); | |||||
| if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { | |||||
| onDrop(e.dataTransfer.files) | |||||
| } | |||||
| } | |||||
| return ( | |||||
| <div className={`draggable ${dragging ? "draggable--dragging" : ""} ${className || ""}`} | |||||
| onDragEnter={handleDragEnter} onDragStart={handleDragStart} {...otherAtributes}> | |||||
| {dragging && <div className="draggable-overlay" onDragLeave={handleDragLeave} onDrop={handleDrop} /> } | |||||
| <div className="draggable-content"> | |||||
| {children} | |||||
| </div> | |||||
| <Loader loading={isLoading}/> | |||||
| </div> | |||||
| ); | |||||
| } | |||||
| export default DragAndDrop; | |||||
| @@ -0,0 +1,21 @@ | |||||
| .draggable { | |||||
| position: relative; | |||||
| &.draggable--dragging { | |||||
| .draggable-content { | |||||
| transition: 0.3s; | |||||
| scale: 1.2 | |||||
| } | |||||
| } | |||||
| .draggable-overlay { | |||||
| position: absolute; | |||||
| background: transparent; | |||||
| left: 0; | |||||
| top: 0; | |||||
| height: 100%; | |||||
| width: 100%; | |||||
| border-radius: 25px; | |||||
| z-index: 9999; | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,49 @@ | |||||
| import {useOutsideAlerter} from "../hooks/outside-click-alerter"; | |||||
| import * as React from "react"; | |||||
| import {useNavigate} from "react-router-dom"; | |||||
| import {useRef, useState} from "react"; | |||||
| import {Edit, Person} from '@mui/icons-material'; | |||||
| import {Logo} from "./logo"; | |||||
| import {Button, IconButton} from "@mui/material"; | |||||
| import "./header.scss"; | |||||
| import {useAuth} from "../services/authentication/use-auth"; | |||||
| import {useInstances} from "../services/instances/use-instances"; | |||||
| export default function Header() { | |||||
| const {selectedInstance} = useInstances(); | |||||
| const navigate = useNavigate(); | |||||
| const {userData, userManager} = useAuth(); | |||||
| const [showUserDropout, setShowUserDropout] = useState(false); | |||||
| const wrapperRef = useRef(null); | |||||
| useOutsideAlerter(wrapperRef, () => { | |||||
| setShowUserDropout(false); | |||||
| }); | |||||
| const handleSignOut = async () => { | |||||
| await userManager.signoutRedirect(); | |||||
| navigate("/"); | |||||
| } | |||||
| const handleUserIconClicked = () => { | |||||
| setShowUserDropout(!showUserDropout); | |||||
| } | |||||
| return <header id="admin-header" ref={wrapperRef}> | |||||
| <div id="logo" onClick={() => navigate("/")}><Logo/></div> | |||||
| {selectedInstance && <Button variant="contained" onClick={() => navigate("/")} color="warning"> | |||||
| <Edit /> {selectedInstance.title} | |||||
| </Button>} | |||||
| {userData && | |||||
| <div id="user-icon"> | |||||
| <IconButton aria-label="person" onClick={handleUserIconClicked}> | |||||
| <Person/> | |||||
| </IconButton> | |||||
| {showUserDropout && userData && <div id="user-icon-dropout"> | |||||
| <div>{userData.profile?.email}</div> | |||||
| {userData ? <div><Button variant="contained" onClick={handleSignOut}>Sign out</Button></div> : null} | |||||
| </div>} | |||||
| </div>} | |||||
| </header>; | |||||
| } | |||||
| @@ -0,0 +1,69 @@ | |||||
| @import "../variables"; | |||||
| header#admin-header { | |||||
| margin: 0 auto; | |||||
| width: 100%; | |||||
| max-width: 1280px; | |||||
| flex: 0 0 $headerSize; | |||||
| position: relative; | |||||
| display: flex; | |||||
| align-items: center; | |||||
| justify-content: space-between; | |||||
| #logo { | |||||
| flex: 0 0 calc($headerSize - 15px); | |||||
| height: calc($headerSize - 20px); | |||||
| margin: 10px calc($pageMargin + $headerOffset); | |||||
| cursor: pointer; | |||||
| svg { | |||||
| width: 100%; | |||||
| height: 100%; | |||||
| color: $accentColor; | |||||
| } | |||||
| } | |||||
| #user-icon { | |||||
| flex: 0 0 calc($headerSize - 20px); | |||||
| height: calc($headerSize - 20px); | |||||
| margin: 10px calc($pageMargin + $headerOffset); | |||||
| position: relative; | |||||
| > button { | |||||
| width: 100%; | |||||
| height: 100%; | |||||
| border: 3px solid $accentColor; | |||||
| border-radius: 50%; | |||||
| padding: 5px; | |||||
| svg { | |||||
| width: 100%; | |||||
| height: 100%; | |||||
| color: $accentColor; | |||||
| } | |||||
| } | |||||
| } | |||||
| #user-icon-dropout { | |||||
| position: absolute; | |||||
| background: white; | |||||
| bottom: -130px; | |||||
| right: -$headerOffset; | |||||
| display: flex; | |||||
| flex-direction: column; | |||||
| justify-content: space-between; | |||||
| height: 120px; | |||||
| border: 2px solid $borderColor; | |||||
| border-radius: $borderRadius; | |||||
| padding: 15px 50px 30px; | |||||
| line-height: 2.2em; | |||||
| z-index: 999; | |||||
| color: #000; | |||||
| button { | |||||
| bottom: 0; | |||||
| width: 100%; | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,16 @@ | |||||
| export function Logo() { | |||||
| return <svg width="50px" height="40px" fill="currentcolor" viewBox="0 0 49 40"> | |||||
| <g id="surface1"> | |||||
| <path d="M 15.554688 0.195312 C 15.027344 0.40625 14.472656 0.863281 14.191406 1.3125 C 14.019531 1.585938 13.792969 2.351562 13.792969 2.640625 C 13.792969 2.695312 17.792969 2.730469 24.507812 2.730469 L 35.222656 2.730469 L 35.222656 2.542969 C 35.222656 2.210938 35.023438 1.539062 34.824219 1.222656 C 34.554688 0.785156 34.078125 0.40625 33.515625 0.183594 C 33.046875 0 32.96875 0 24.527344 0.0078125 C 16.144531 0.0078125 15.996094 0.0195312 15.554688 0.195312 Z M 15.554688 0.195312 "/> | |||||
| <path d="M 7.289062 3.707031 C 4.960938 3.75 4.683594 3.769531 4.25 3.933594 C 3.390625 4.269531 3.113281 4.402344 2.664062 4.726562 C 1.441406 5.582031 0.695312 6.65625 0.261719 8.160156 C 0.113281 8.679688 0.09375 9.445312 0.0859375 21.761719 L 0.0703125 34.816406 L 0.261719 35.429688 C 0.605469 36.539062 1.015625 37.210938 1.867188 38.070312 C 2.6875 38.917969 3.226562 39.261719 4.363281 39.683594 L 4.988281 39.910156 L 9.542969 39.945312 C 12.050781 39.957031 14.367188 39.9375 14.6875 39.902344 L 15.277344 39.832031 L 14.601562 39.339844 C 13.371094 38.433594 12.023438 37.085938 11.253906 35.976562 L 10.898438 35.476562 L 6.019531 35.476562 L 5.578125 35.253906 C 5.101562 35.007812 4.773438 34.640625 4.617188 34.164062 C 4.46875 33.730469 4.46875 9.894531 4.617188 9.410156 C 4.738281 8.980469 5.195312 8.433594 5.585938 8.238281 C 5.855469 8.105469 7.46875 8.097656 24.5 8.097656 C 39.996094 8.097656 43.171875 8.117188 43.402344 8.222656 C 43.828125 8.398438 44.210938 8.792969 44.417969 9.269531 L 44.601562 9.691406 L 44.539062 21.902344 C 44.488281 33.496094 44.472656 34.128906 44.324219 34.4375 C 44.125 34.839844 43.605469 35.28125 43.1875 35.394531 C 42.96875 35.449219 39.371094 35.476562 32.820312 35.457031 C 23.503906 35.429688 22.738281 35.421875 22.339844 35.273438 C 22.105469 35.195312 21.65625 35.035156 21.34375 34.929688 C 20.066406 34.507812 18.773438 33.660156 17.664062 32.535156 C 16.996094 31.855469 16.136719 30.757812 16.136719 30.582031 C 16.136719 30.554688 16.066406 30.421875 15.972656 30.300781 C 15.746094 29.964844 15.320312 28.847656 15.085938 27.984375 C 14.921875 27.339844 14.894531 27.042969 14.894531 25.75 C 14.894531 24.628906 14.9375 24.136719 15.042969 23.8125 C 15.121094 23.566406 15.183594 23.300781 15.183594 23.214844 C 15.183594 22.957031 15.789062 21.46875 16.164062 20.820312 C 17.757812 18.011719 20.371094 16.285156 23.570312 15.933594 C 26.972656 15.554688 30.652344 17.296875 32.542969 20.195312 C 33.097656 21.046875 33.660156 22.296875 33.886719 23.167969 C 34.070312 23.882812 34.09375 24.171875 34.09375 25.597656 C 34.09375 27.492188 33.949219 28.34375 33.390625 29.570312 C 33.019531 30.371094 32.28125 31.550781 31.734375 32.175781 L 31.386719 32.570312 L 34.136719 32.554688 L 36.878906 32.527344 L 37.046875 32.21875 C 37.816406 30.765625 38.363281 28.792969 38.527344 26.867188 C 38.804688 23.75 38.058594 20.652344 36.402344 18 C 35.007812 15.757812 33.160156 14.074219 30.753906 12.871094 C 27.753906 11.355469 23.632812 11.03125 20.386719 12.050781 C 17.332031 13 14.601562 15.054688 12.855469 17.695312 C 11.851562 19.226562 11.148438 20.949219 10.707031 22.976562 C 10.472656 24.058594 10.472656 27.351562 10.707031 28.433594 C 11.582031 32.457031 13.492188 35.386719 16.640625 37.527344 C 17.984375 38.441406 19.617188 39.144531 21.507812 39.628906 C 22.253906 39.824219 22.296875 39.824219 33.203125 39.851562 L 44.148438 39.886719 L 44.773438 39.65625 C 46.570312 39.023438 47.847656 37.800781 48.53125 36.054688 C 49.042969 34.753906 49.027344 35.441406 48.964844 21.523438 C 48.929688 13.566406 48.878906 8.625 48.828125 8.40625 C 48.226562 5.933594 46.285156 4.09375 43.855469 3.6875 C 43.414062 3.617188 38.816406 3.601562 26.546875 3.617188 C 17.359375 3.636719 8.691406 3.671875 7.289062 3.707031 Z M 7.289062 3.707031 "/> | |||||
| <path d="M 6.707031 10.007812 C 6.628906 10.035156 6.59375 10.265625 6.59375 10.703125 L 6.59375 11.355469 L 13.621094 11.355469 L 13.621094 10.703125 C 13.621094 10.210938 13.585938 10.042969 13.492188 10.007812 C 13.335938 9.9375 6.855469 9.9375 6.707031 10.007812 Z M 6.707031 10.007812 "/> | |||||
| <path d="M 22.902344 17.121094 C 20.65625 17.613281 18.703125 18.890625 17.523438 20.660156 C 17.132812 21.242188 17.109375 21.320312 17.28125 21.257812 C 17.394531 21.222656 18.425781 21.179688 19.582031 21.152344 C 21.246094 21.117188 21.90625 21.136719 22.789062 21.257812 C 24.039062 21.425781 25.339844 21.714844 26.242188 22.042969 C 26.574219 22.164062 26.859375 22.246094 26.878906 22.226562 C 26.988281 22.113281 25.792969 19.929688 24.570312 18.027344 C 23.824219 16.867188 23.902344 16.910156 22.902344 17.121094 Z M 22.902344 17.121094 "/> | |||||
| <path d="M 24.863281 17.21875 C 26.226562 19.277344 26.929688 20.535156 27.5625 22.015625 C 28.089844 23.25 28.324219 24.03125 28.691406 25.738281 L 28.757812 26.082031 L 29.296875 25.253906 C 30.097656 24.003906 31.667969 20.96875 31.667969 20.660156 C 31.667969 20.449219 30.234375 18.96875 29.609375 18.546875 C 28.292969 17.648438 26.816406 17.09375 25.472656 17.015625 L 24.691406 16.964844 Z M 24.863281 17.21875 "/> | |||||
| <path d="M 31.96875 21.671875 C 31.855469 22.035156 30.796875 24.136719 30.261719 25.0625 C 29.480469 26.390625 28.714844 27.386719 27.546875 28.582031 C 26.652344 29.507812 26.519531 29.675781 26.667969 29.71875 C 26.945312 29.804688 29.636719 29.78125 30.625 29.683594 C 31.128906 29.628906 31.753906 29.578125 32.011719 29.558594 L 32.492188 29.542969 L 32.644531 29.144531 C 33.082031 28.027344 33.175781 27.472656 33.175781 25.925781 C 33.183594 24.613281 33.160156 24.339844 32.976562 23.679688 C 32.777344 22.929688 32.394531 22 32.160156 21.644531 L 32.03125 21.460938 Z M 31.96875 21.671875 "/> | |||||
| <path d="M 18.261719 22.007812 C 17.742188 22.042969 17.144531 22.078125 16.945312 22.085938 L 16.585938 22.09375 L 16.363281 22.683594 C 15.484375 25.042969 15.609375 27.570312 16.710938 29.78125 C 16.855469 30.070312 16.960938 30.183594 16.988281 30.105469 C 17.046875 29.902344 17.976562 28.027344 18.304688 27.421875 C 19.214844 25.765625 20.335938 24.277344 21.636719 23 L 22.617188 22.035156 L 22.042969 21.972656 C 21.359375 21.910156 19.570312 21.929688 18.261719 22.007812 Z M 18.261719 22.007812 "/> | |||||
| <path d="M 20.292969 25.667969 C 20.128906 25.855469 19.171875 27.351562 19.171875 27.410156 C 19.171875 27.4375 19.050781 27.667969 18.902344 27.921875 C 18.757812 28.179688 18.359375 28.933594 18.027344 29.605469 C 17.308594 31.046875 17.289062 30.90625 18.347656 32.007812 C 19.796875 33.511719 21.6875 34.445312 23.546875 34.570312 L 24.40625 34.621094 L 23.972656 33.96875 C 22.566406 31.847656 21.523438 29.78125 20.996094 28.035156 C 20.832031 27.492188 20.5 26.082031 20.457031 25.714844 C 20.429688 25.511719 20.421875 25.511719 20.292969 25.667969 Z M 20.292969 25.667969 "/> | |||||
| <path d="M 22.46875 29.675781 C 22.574219 29.886719 22.722656 30.203125 22.800781 30.371094 C 23.46875 31.75 25.273438 34.59375 25.488281 34.59375 C 25.878906 34.59375 26.800781 34.394531 27.492188 34.15625 C 29.332031 33.53125 30.9375 32.261719 31.933594 30.632812 L 32.074219 30.414062 L 29.332031 30.414062 C 26.21875 30.414062 25.359375 30.308594 23.511719 29.699219 C 23.292969 29.621094 23.007812 29.535156 22.859375 29.5 C 22.722656 29.453125 22.53125 29.394531 22.445312 29.355469 C 22.296875 29.296875 22.296875 29.3125 22.46875 29.675781 Z M 22.46875 29.675781 "/> | |||||
| </g> | |||||
| </svg>; | |||||
| } | |||||
| @@ -0,0 +1,20 @@ | |||||
| import {useEffect} from "react"; | |||||
| /** | |||||
| * Hook that alerts clicks outside of the passed ref | |||||
| */ | |||||
| export function useOutsideAlerter(ref, handler) { | |||||
| useEffect(() => { | |||||
| function handleClickOutside(event) { | |||||
| if (ref.current && !ref.current.contains(event.target)) { | |||||
| handler(event); | |||||
| } | |||||
| } | |||||
| // Bind the event listener | |||||
| document.addEventListener("mousedown", handleClickOutside); | |||||
| return () => { | |||||
| // Unbind the event listener on clean up | |||||
| document.removeEventListener("mousedown", handleClickOutside); | |||||
| }; | |||||
| }, [ref, handler]); | |||||
| } | |||||
| @@ -0,0 +1,32 @@ | |||||
| body { | |||||
| margin: 0; | |||||
| font-size: 16pt; | |||||
| font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', | |||||
| 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', | |||||
| sans-serif; | |||||
| -webkit-font-smoothing: antialiased; | |||||
| -moz-osx-font-smoothing: grayscale; | |||||
| } | |||||
| code { | |||||
| font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', | |||||
| monospace; | |||||
| } | |||||
| #root { | |||||
| background: #fff; | |||||
| min-height: 100vh; | |||||
| font-size: 1rem; | |||||
| display: flex; | |||||
| flex-direction: column; | |||||
| } | |||||
| *, | |||||
| *::before, | |||||
| *::after { | |||||
| box-sizing: border-box; | |||||
| } | |||||
| input { | |||||
| font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; | |||||
| } | |||||
| @@ -0,0 +1,55 @@ | |||||
| import React, {useLayoutEffect} from 'react'; | |||||
| import ReactDOM from 'react-dom/client'; | |||||
| import './index.css'; | |||||
| import {BrowserRouter, Navigate, Route, Routes, useLocation} from "react-router-dom"; | |||||
| import '@fontsource/roboto/300.css'; | |||||
| import '@fontsource/roboto/400.css'; | |||||
| import '@fontsource/roboto/500.css'; | |||||
| import '@fontsource/roboto/700.css'; | |||||
| import Keycloak from "./services/authentication/keycloak"; | |||||
| import PortfolioManager from "./pages/portfolio-manager"; | |||||
| import AlbumManager from "./pages/album-manager"; | |||||
| import {InstanceSelectorPage} from "./pages/instance-selector-page"; | |||||
| import {NoAccessPage} from "./pages/no-access"; | |||||
| import {InstancesProvider} from "./services/instances/instances-context"; | |||||
| import {AdminLayout} from "./layout"; | |||||
| const root = ReactDOM.createRoot(document.getElementById('root')); | |||||
| root.render( | |||||
| <React.StrictMode> | |||||
| <BrowserRouter> | |||||
| <ScrollToTop> | |||||
| <Keycloak> | |||||
| <InstancesProvider> | |||||
| <AdminLayout> | |||||
| <Routes> | |||||
| <Route index path="/" element={<InstanceSelectorPage/>}/> | |||||
| <Route path="/no-access" element={<NoAccessPage/>}/> | |||||
| <Route path="/:instanceId"> | |||||
| {/*<Route index element={<ManageInstancePage/>}/>*/} | |||||
| <Route index path="" element={<Navigate to="albums" />} /> | |||||
| <Route path="albums" element={<PortfolioManager/>} /> | |||||
| <Route path="albums/:albumId" element={<AlbumManager/>}/> | |||||
| <Route path="signin-keycloak" element={<Navigate to="/"/>}/> | |||||
| <Route path="signin-keycloak" element={<Navigate to="/"/>}/> | |||||
| </Route> | |||||
| <Route path="*" element={<Navigate to={"/no-access"}/>}/> | |||||
| </Routes> | |||||
| </AdminLayout> | |||||
| </InstancesProvider> | |||||
| </Keycloak> | |||||
| </ScrollToTop> | |||||
| </BrowserRouter> | |||||
| </React.StrictMode> | |||||
| ); | |||||
| function ScrollToTop({children}) { | |||||
| const location = useLocation(); | |||||
| useLayoutEffect(() => { | |||||
| document.documentElement.scrollTo(0, 0); | |||||
| }, [location.pathname]); | |||||
| return children | |||||
| } | |||||
| @@ -0,0 +1,68 @@ | |||||
| import * as React from "react"; | |||||
| import Header from "./components/header"; | |||||
| import {useNavigate} from "react-router-dom"; | |||||
| import {useEffect} from "react"; | |||||
| import "./admin.scss"; | |||||
| import Loader from "./components/Loader"; | |||||
| import {createTheme} from '@mui/material/styles'; | |||||
| import {ThemeProvider} from "@mui/material"; | |||||
| import {useAuth} from "./services/authentication/use-auth"; | |||||
| import {useInstances} from "./services/instances/use-instances"; | |||||
| const theme = createTheme({ | |||||
| palette: { | |||||
| primary: { | |||||
| main: '#de5200' | |||||
| } | |||||
| }, | |||||
| }); | |||||
| export function AdminLayout({children}) { | |||||
| const navigate = useNavigate(); | |||||
| const {isLoading, userData, userManager, signIn} = useAuth(); | |||||
| const {isLoading: instancesIsLoading} = useInstances(); | |||||
| useEffect(() => { | |||||
| async function handleLogin() { | |||||
| try { | |||||
| await userManager.signinSilentCallback(); | |||||
| } catch (err) { | |||||
| } | |||||
| } | |||||
| async function silentLogin() { | |||||
| try { | |||||
| await userManager.signinSilent(); | |||||
| } catch (err) { | |||||
| await signIn(); | |||||
| } | |||||
| } | |||||
| if (!userData && !isLoading) { | |||||
| if (window.location.href.indexOf("/signin-keycloak") >= 0) { | |||||
| handleLogin(); | |||||
| } else if (window.location.href.indexOf("/signin-keycloak") >= 0) { | |||||
| navigate("/"); | |||||
| } else { | |||||
| silentLogin(); | |||||
| } | |||||
| } | |||||
| }, [userData, isLoading, userManager, navigate, signIn]); | |||||
| return <ThemeProvider theme={theme}> | |||||
| <Header/> | |||||
| <div id="admin-page"> | |||||
| <main id="page"> | |||||
| {userData ? <> | |||||
| {(isLoading || instancesIsLoading) && <Loader/>} | |||||
| {!isLoading && !instancesIsLoading && children} | |||||
| </> | |||||
| : <Loader/> | |||||
| } | |||||
| </main> | |||||
| </div> | |||||
| </ThemeProvider> | |||||
| } | |||||
| @@ -0,0 +1,128 @@ | |||||
| @import "./variables"; | |||||
| @keyframes sb_slide_negative_left_to_right { | |||||
| from { | |||||
| transform: translateX(-$sidebar_width_1920); | |||||
| @media(max-width: 1920px) { | |||||
| transform: translateX(-$sidebar_width_1024_1920); | |||||
| } | |||||
| @media(max-width: 1024px) { | |||||
| transform: translateX(-$sidebar_width_768_1024); | |||||
| } | |||||
| @media(max-width: 768px) { | |||||
| transform: translateX(-$sidebar_width_480_768); | |||||
| } | |||||
| } | |||||
| to { | |||||
| transform: translateX(0); | |||||
| } | |||||
| } | |||||
| @mixin sb_slide_negative_left_to_right() { | |||||
| transition: 0.6s; | |||||
| animation: ease-out; | |||||
| animation-name: sb_slide_negative_left_to_right; | |||||
| transform: translateX(0); | |||||
| } | |||||
| @keyframes sb_slide_right_to_negative_left { | |||||
| from { | |||||
| transform: translateX(0); | |||||
| } | |||||
| to { | |||||
| transform: translateX(-$sidebar_width_1920); | |||||
| @media(max-width: 1920px) { | |||||
| transform: translateX(-$sidebar_width_1024_1920); | |||||
| } | |||||
| @media(max-width: 1024px) { | |||||
| transform: translateX(-$sidebar_width_768_1024); | |||||
| } | |||||
| @media(max-width: 768px) { | |||||
| transform: translateX(-$sidebar_width_480_768); | |||||
| } | |||||
| } | |||||
| } | |||||
| @mixin sb_slide_right_to_negative_left() { | |||||
| transition: 0.6s; | |||||
| animation: ease-out; | |||||
| animation-name: sb_slide_right_to_negative_left; | |||||
| transform: translateX(-1 * $sidebar_width_1920); | |||||
| @media(max-width: 1920px) { | |||||
| transform: translateX(-1 * $sidebar_width_1024_1920); | |||||
| } | |||||
| @media(max-width: 1024px) { | |||||
| transform: translateX(-1 * $sidebar_width_768_1024); | |||||
| } | |||||
| @media(max-width: 768px) { | |||||
| transform: translateX(-1 * $sidebar_width_480_768); | |||||
| } | |||||
| } | |||||
| @keyframes sb_slide_left_to_right { | |||||
| from { | |||||
| transform: translateX(0); | |||||
| } | |||||
| to { | |||||
| transform: translateX($sidebar_width_1920); | |||||
| @media(max-width: 1920px) { | |||||
| transform: translateX($sidebar_width_1024_1920); | |||||
| } | |||||
| @media(max-width: 1024px) { | |||||
| transform: translateX($sidebar_width_768_1024); | |||||
| } | |||||
| @media(max-width: 768px) { | |||||
| transform: translateX($sidebar_width_480_768); | |||||
| } | |||||
| } | |||||
| } | |||||
| @mixin sb_slide_left_to_right() { | |||||
| transition: 0.6s; | |||||
| animation: ease-out; | |||||
| animation-name: sb_slide_left_to_right; | |||||
| transform: translateX($sidebar_width_1920); | |||||
| @media(max-width: 1920px) { | |||||
| transform: translateX($sidebar_width_1024_1920); | |||||
| } | |||||
| @media(max-width: 1024px) { | |||||
| transform: translateX($sidebar_width_768_1024); | |||||
| } | |||||
| @media(max-width: 768px) { | |||||
| transform: translateX($sidebar_width_480_768); | |||||
| } | |||||
| } | |||||
| @keyframes sb_slide_right_to_left { | |||||
| from { | |||||
| transform: translateX($sidebar_width_1920); | |||||
| @media(max-width: 1920px) { | |||||
| transform: translateX($sidebar_width_1024_1920); | |||||
| } | |||||
| @media(max-width: 1024px) { | |||||
| transform: translateX($sidebar_width_768_1024); | |||||
| } | |||||
| @media(max-width: 768px) { | |||||
| transform: translateX($sidebar_width_480_768); | |||||
| } | |||||
| } | |||||
| to { | |||||
| transform: translateX(0); | |||||
| } | |||||
| } | |||||
| @mixin sb_slide_right_to_left() { | |||||
| transition: 0.6s; | |||||
| animation: ease-out; | |||||
| animation-name: sb_slide_right_to_left; | |||||
| transform: translateX(0); | |||||
| } | |||||
| @@ -0,0 +1,34 @@ | |||||
| import {Photo} from "./photo.js"; | |||||
| export class Album { | |||||
| /** | |||||
| * @param {Album} [album] | |||||
| */ | |||||
| constructor(album) { | |||||
| this._id = album?._id; | |||||
| this.albumId = album?.albumId; | |||||
| this.name = album?.name; | |||||
| this.description = album?.description; | |||||
| this.sort = album?.sort; | |||||
| this.sortOrder = album?.sortOrder; | |||||
| this.photos = album?.photos?.map(photo => new Photo(photo)) || []; | |||||
| this.coverPhoto = this.photos.length > 0 ? this.photos[0] : null; | |||||
| } | |||||
| /** @type {string} **/ | |||||
| id; | |||||
| /** @type {string} **/ | |||||
| albumId; | |||||
| /** @type {string} **/ | |||||
| name; | |||||
| /** @type {string} **/ | |||||
| description; | |||||
| /** @type {"manual"|"auto"} **/ | |||||
| sort; | |||||
| /** @type {number} **/ | |||||
| sortOrder; | |||||
| /** @type {Photo[]} **/ | |||||
| photos; | |||||
| /** @type {Photo} **/ | |||||
| coverPhoto; | |||||
| } | |||||
| @@ -0,0 +1,12 @@ | |||||
| import {Photo} from "./photo"; | |||||
| export class Instance { | |||||
| constructor(instance) { | |||||
| this._id = instance?._id; | |||||
| this.instanceId = instance?.instanceId; | |||||
| this.title = instance?.title; | |||||
| this.subtitle = instance?.subtitle; | |||||
| this.coverPhoto = instance?.coverPhoto ? new Photo(instance.coverPhoto) : null; | |||||
| this.urls = instance?.urls?.map(u => u) || []; | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,29 @@ | |||||
| export class Photo { | |||||
| /** | |||||
| * @param {Photo} photo | |||||
| */ | |||||
| constructor(photo) { | |||||
| this.photoId = photo?.photoId; | |||||
| this.albumId = photo?.albumId; | |||||
| this.name = photo?.name; | |||||
| this.fileName = photo?.fileName; | |||||
| this.mimeType = photo?.mimeType; | |||||
| this.s3Path = photo?.s3Path; | |||||
| this.description = photo?.description; | |||||
| this.sortOrder = photo?.sortOrder; | |||||
| this.url = `${window._env_.REACT_APP_API_URL}/api/s3/${this.s3Path}`; | |||||
| } | |||||
| /** @type {string} **/ | |||||
| photoId; | |||||
| /** @type {string} **/ | |||||
| albumId; | |||||
| /** @type {string} **/ | |||||
| name; | |||||
| /** @type {string} **/ | |||||
| s3Path; | |||||
| /** @type {string} **/ | |||||
| description; | |||||
| /** @type {number} **/ | |||||
| sortOrder; | |||||
| } | |||||
| @@ -0,0 +1,302 @@ | |||||
| import * as React from "react"; | |||||
| import {useLocation, useNavigate} from "react-router-dom"; | |||||
| import {AddAPhoto, ArrowCircleLeftOutlined, MoveDown, MoveUp} from "@mui/icons-material"; | |||||
| import {useRef, useState} from "react"; | |||||
| import useAlbumsApi from "../services/albums-api"; | |||||
| import {Button, IconButton, Input, TextField} from "@mui/material"; | |||||
| import {Photo} from "../models/photo"; | |||||
| import DragAndDrop from "../components/drag-and-drop/drag-and-drop"; | |||||
| import {Album} from "../models/album"; | |||||
| import Loader from "../components/Loader"; | |||||
| import "./album-manager.scss"; | |||||
| import {useInstances} from "../services/instances/use-instances"; | |||||
| function AlbumManager() { | |||||
| const {state} = useLocation(); | |||||
| const {album} = state; // Read values passed on state | |||||
| const {selectedInstance} = useInstances(); | |||||
| const navigate = useNavigate(); | |||||
| const { | |||||
| postAlbum, | |||||
| patchAlbum, | |||||
| patchPhoto, | |||||
| uploadPhotos, | |||||
| deletePhoto, | |||||
| movePhotoDown, | |||||
| movePhotoUp | |||||
| } = useAlbumsApi(); | |||||
| const [name, setName] = useState(album.name || ''); | |||||
| const [description, setDescription] = useState(album.description || ''); | |||||
| const [sort] = useState(album.sort || "auto" ); | |||||
| const [photosToUpdate, setPhotosToUpdate] = useState(album.photos.map(x => new Photo(x))); | |||||
| const [uploadApiError, setUploadApiError] = useState(null); | |||||
| const [apiError, setApiError] = useState(null); | |||||
| const [uploadSuccess, setUploadSuccess] = useState(null); | |||||
| const [isUploading, setIsUploading] = useState(false); | |||||
| const fileInputRef = useRef(); | |||||
| const handleSaveEdit = async (e) => { | |||||
| try { | |||||
| if (hasChanges()) { | |||||
| setApiError(null); | |||||
| let albumToUpdate = new Album({ | |||||
| ...album, | |||||
| name, | |||||
| description | |||||
| }); | |||||
| let updatedAlbum; | |||||
| if (album.albumId) { | |||||
| updatedAlbum = await patchAlbum(selectedInstance, albumToUpdate); | |||||
| } else { | |||||
| updatedAlbum = await postAlbum(selectedInstance, albumToUpdate); | |||||
| } | |||||
| navigate(`/${selectedInstance.instanceId}/albums/${updatedAlbum.albumId}`, {state: {album: updatedAlbum}}); | |||||
| } | |||||
| } catch (err) { | |||||
| if (typeof err === "string" && err.toLowerCase().indexOf("duplicate") >= 0) { | |||||
| setApiError("Name already in use"); | |||||
| } else { | |||||
| console.error(err); | |||||
| setApiError("An error occurred trying to save the album, contact the system administrator"); | |||||
| } | |||||
| } | |||||
| } | |||||
| const handleSavePhotoEdit = async (idx) => { | |||||
| try { | |||||
| setApiError(null); | |||||
| if (photoHasChanges(idx)) { | |||||
| const updatedAlbum = await patchPhoto( | |||||
| selectedInstance, | |||||
| album.albumId, | |||||
| photosToUpdate[idx] | |||||
| ); | |||||
| navigate(`/${selectedInstance.instanceId}/albums/${updatedAlbum.albumId}`, {state: {album: updatedAlbum}}); | |||||
| } | |||||
| } catch (err) { | |||||
| if (typeof err === "string" && err.toLowerCase().indexOf("duplicate") >= 0) { | |||||
| setApiError("Name already in use"); | |||||
| } else { | |||||
| console.error(err); | |||||
| setApiError("An error occurred trying to save the photo, contact the system administrator"); | |||||
| } | |||||
| } | |||||
| } | |||||
| const handleMovePhotoDown = async (idx) => { | |||||
| try { | |||||
| setApiError(null); | |||||
| const updatedAlbum = await movePhotoDown( | |||||
| selectedInstance, | |||||
| album.albumId, | |||||
| photosToUpdate[idx].photoId | |||||
| ); | |||||
| const orgPhoto = photosToUpdate[idx]; | |||||
| photosToUpdate[idx] = photosToUpdate[idx + 1]; | |||||
| photosToUpdate[idx + 1] = orgPhoto; | |||||
| setPhotosToUpdate([...photosToUpdate]); | |||||
| navigate(`/${selectedInstance.instanceId}/albums/${updatedAlbum.albumId}`, {state: {album: updatedAlbum}}); | |||||
| } catch (err) { | |||||
| console.error(err); | |||||
| setApiError("An error occurred trying to save the photo, contact the system administrator"); | |||||
| } | |||||
| } | |||||
| const handleMovePhotoUp = async (idx) => { | |||||
| try { | |||||
| setApiError(null); | |||||
| const updatedAlbum = await movePhotoUp( | |||||
| selectedInstance, | |||||
| album.albumId, | |||||
| photosToUpdate[idx].photoId | |||||
| ); | |||||
| const orgPhoto = photosToUpdate[idx]; | |||||
| photosToUpdate[idx] = photosToUpdate[idx - 1]; | |||||
| photosToUpdate[idx - 1] = orgPhoto; | |||||
| setPhotosToUpdate([...photosToUpdate]); | |||||
| navigate(`/${selectedInstance.instanceId}/albums/${updatedAlbum.albumId}`, {state: {album: updatedAlbum}}); | |||||
| } catch (err) { | |||||
| console.error(err); | |||||
| setApiError("An error occurred trying to save the photo, contact the system administrator"); | |||||
| } | |||||
| } | |||||
| const handleDeletePhoto = async (e, photoId) => { | |||||
| try { | |||||
| setApiError(null); | |||||
| const updatedAlbum = await deletePhoto(selectedInstance, album.albumId, photoId); | |||||
| setPhotosToUpdate(updatedAlbum.photos); | |||||
| navigate(`/${selectedInstance.instanceId}/albums/${updatedAlbum.albumId}`, {state: {album: updatedAlbum}}); | |||||
| } catch (err) { | |||||
| setApiError("An error occurred trying to save the photo, contact the system administrator"); | |||||
| } | |||||
| } | |||||
| const handlePhotoNameChange = (photoIndex, e) => { | |||||
| photosToUpdate[photoIndex].name = e.target.value; | |||||
| setPhotosToUpdate([...photosToUpdate]); | |||||
| } | |||||
| const handlePhotoDescriptionChange = (photoIndex, e) => { | |||||
| photosToUpdate[photoIndex].description = e.target.value; | |||||
| setPhotosToUpdate([...photosToUpdate]); | |||||
| } | |||||
| const hasChanges = () => album.sort !== sort || album.name !== name || album.description !== description; | |||||
| const photoHasChanges = (idx) => { | |||||
| return (photosToUpdate[idx].name || "") !== (album.photos[idx].name || "") || | |||||
| (photosToUpdate[idx].description || "") !== (album.photos[idx].description || ""); | |||||
| } | |||||
| const handleNavigateToOverview = (e) => { | |||||
| navigate(`/${selectedInstance.instanceId}`); | |||||
| } | |||||
| const handleDrop = async (files) => { | |||||
| await handleFiles(files) | |||||
| } | |||||
| const handleFiles = async (files) => { | |||||
| try { | |||||
| setUploadApiError(null); | |||||
| setUploadSuccess(null); | |||||
| setIsUploading(true); | |||||
| const arrFiles = [...files]; | |||||
| const formData = new FormData(); | |||||
| for (let file of arrFiles) { | |||||
| formData.append('photos', file); | |||||
| } | |||||
| const updatedAlbum = await uploadPhotos(selectedInstance, album.albumId, formData); | |||||
| setPhotosToUpdate(updatedAlbum.photos); | |||||
| setUploadSuccess(true); | |||||
| navigate(`/${selectedInstance.instanceId}/albums/${updatedAlbum.albumId}`, {state: {album: updatedAlbum}}); | |||||
| } catch (err) { | |||||
| if (typeof err === "string" && err.toLowerCase().indexOf("already exist") >= 0) { | |||||
| setUploadApiError(err); | |||||
| } else if (typeof err === "string" && err.toLowerCase().indexOf("mimetype") >= 0) { | |||||
| setUploadApiError(err); | |||||
| } else { | |||||
| console.error(err); | |||||
| setUploadApiError("An error occurred trying to upload your photo's, contact the system administrator"); | |||||
| } | |||||
| } finally { | |||||
| setIsUploading(false); | |||||
| } | |||||
| } | |||||
| return selectedInstance && <div id="admin-page-manage-albums"> | |||||
| <div id="page-title"> | |||||
| <div id="left-part"> | |||||
| <IconButton onClick={handleNavigateToOverview} color="primary"> | |||||
| <ArrowCircleLeftOutlined id="navigate-back"/> | |||||
| </IconButton> | |||||
| <div id="edit-title" className="input-element"> | |||||
| <Input onKeyUp={(e) => e.key === "Enter" && handleSaveEdit(e)} | |||||
| fullWidth={true} | |||||
| type="text" | |||||
| onChange={(e) => setName(e.target.value)} | |||||
| placeholder="Name..." | |||||
| tabIndex={1} | |||||
| value={name} | |||||
| /> | |||||
| </div> | |||||
| <div className="photos-count">({album.photos.length} photos)</div> | |||||
| </div> | |||||
| </div> | |||||
| {apiError && <div className="error-box">{apiError}</div>} | |||||
| {album && <div> | |||||
| <div id="album-description"> | |||||
| <div className="input-element"> | |||||
| <TextField multiline={true} fullWidth={true} rows={8} | |||||
| onChange={(e) => setDescription(e.target.value)} | |||||
| placeholder="Description..." | |||||
| tabIndex={2} | |||||
| value={description} | |||||
| /> | |||||
| </div> | |||||
| </div> | |||||
| <div className="save-button"> | |||||
| <Button id="page-save" variant="contained" color="info" disabled={!hasChanges()} | |||||
| onClick={handleSaveEdit}> | |||||
| Save | |||||
| </Button> | |||||
| </div> | |||||
| {album?.albumId && | |||||
| <div id="photo-list"> | |||||
| <div className="photo photo--new"> | |||||
| <div className="cover-photo"> | |||||
| <DragAndDrop onDrop={handleDrop} onClick={(e) => fileInputRef.current.click(e)}> | |||||
| {!isUploading && <AddAPhoto className="no-photos" color="primary"/>} | |||||
| <Loader loading={isUploading}/> | |||||
| </DragAndDrop> | |||||
| <input type="file" id="photosUpload" ref={fileInputRef} multiple accept="image/*" | |||||
| onChange={(e) => handleFiles(e.target.files)}></input> | |||||
| {uploadApiError && <div className="upload-error">{uploadApiError}</div>} | |||||
| {uploadSuccess && <div className="upload-success">Photo's uploaded successfully</div>} | |||||
| </div> | |||||
| </div> | |||||
| {photosToUpdate?.map((photo, idx) => | |||||
| <div className="card-layout photo" key={photo.photoId}> | |||||
| <div className="cover-photo"> | |||||
| {<img src={`${photo.url}?width=450&height=450&fit=cover`} alt={photo.name}/>} | |||||
| <div className="file-name"> | |||||
| {photo.fileName} | |||||
| </div> | |||||
| </div> | |||||
| <div className="card-details"> | |||||
| <div className="title input-element"> | |||||
| <TextField fullWidth={true} variant="standard" | |||||
| value={photo.name} | |||||
| onChange={(e) => handlePhotoNameChange(idx, e)} | |||||
| /> | |||||
| </div> | |||||
| <div className="description input-element"> | |||||
| <TextField multiline={true} fullWidth={true} rows={8} | |||||
| onChange={(e) => handlePhotoDescriptionChange(idx, e)} | |||||
| value={photo.description || ""} | |||||
| /> | |||||
| </div> | |||||
| <div className="input-buttons"> | |||||
| <div className="left-buttons"> | |||||
| <Button className="photo-save" variant="contained" color="error" | |||||
| onClick={(e) => handleDeletePhoto(e, photo.photoId)}> | |||||
| Delete | |||||
| </Button> | |||||
| <Button className="photo-save" variant="contained" color="info" | |||||
| disabled={!photoHasChanges(idx)} | |||||
| onClick={() => handleSavePhotoEdit(idx)}> | |||||
| Save | |||||
| </Button> | |||||
| </div> | |||||
| <div className="right-buttons"> | |||||
| <Button className="photo-save" | |||||
| style={{visibility: (idx > 0 ? "visible" : "hidden")}} | |||||
| startIcon={<MoveUp/>} variant="contained" color="action" | |||||
| onClick={() => handleMovePhotoUp(idx)}> | |||||
| Move up | |||||
| </Button> | |||||
| <Button className="photo-save" | |||||
| style={{visibility: (idx < photosToUpdate.length - 1 ? "visible" : "hidden")}} | |||||
| startIcon={<MoveDown/>} variant="contained" color="action" | |||||
| onClick={(e) => handleMovePhotoDown(idx)}> | |||||
| Move down | |||||
| </Button> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| )} | |||||
| </div> | |||||
| } | |||||
| </div>} | |||||
| </div>; | |||||
| } | |||||
| export default AlbumManager; | |||||
| @@ -0,0 +1,119 @@ | |||||
| @import "../variables"; | |||||
| #admin-page-manage-albums { | |||||
| #page-title { | |||||
| #left-part { | |||||
| align-items: flex-end; | |||||
| } | |||||
| @media (max-width: 768px) { | |||||
| flex-direction: column; | |||||
| #left-part { | |||||
| width: 100%; | |||||
| flex-wrap: wrap; | |||||
| button { | |||||
| width: 50px; | |||||
| } | |||||
| #edit-title { | |||||
| flex: 1; | |||||
| } | |||||
| .photos-count { | |||||
| flex: 1 1 100%; | |||||
| margin-left: 70px; | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| #album-description { | |||||
| .MuiFormControl-root { | |||||
| background: white; | |||||
| border-radius: $borderRadius; | |||||
| } | |||||
| } | |||||
| .save-button { | |||||
| margin-top: 15px; | |||||
| display: flex; | |||||
| justify-content: flex-end; | |||||
| } | |||||
| #photo-list { | |||||
| margin-top: 25px; | |||||
| display: grid; | |||||
| grid-template-columns: 1fr; | |||||
| gap: 25px; | |||||
| .photo { | |||||
| padding: 15px; | |||||
| display: flex; | |||||
| flex-direction: row; | |||||
| align-items: flex-start; | |||||
| justify-content: space-between; | |||||
| border: 2px solid $borderColor; | |||||
| border-radius: $borderRadius; | |||||
| position: relative; | |||||
| &.photo--new { | |||||
| border-style: dashed; | |||||
| background: #fff; | |||||
| .cover-photo { | |||||
| height: 150px; | |||||
| flex: 1; | |||||
| .draggable-content { | |||||
| height: 150px; | |||||
| padding-left: calc(50% - 37.5px); | |||||
| display: flex; | |||||
| flex-direction: column; | |||||
| justify-content: center; | |||||
| cursor: pointer; | |||||
| &:hover { | |||||
| transition: 0.3s; | |||||
| scale: 1.2 | |||||
| } | |||||
| #page-loader { | |||||
| position: absolute; | |||||
| padding-left: 0; | |||||
| padding-top: unset; | |||||
| } | |||||
| svg { | |||||
| width: 75px; | |||||
| height: 75px; | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| .title { | |||||
| .MuiInput-input { | |||||
| font-size: 1.4rem; | |||||
| } | |||||
| } | |||||
| @media(max-width: 968px) { | |||||
| flex-direction: column; | |||||
| align-items: center; | |||||
| .photo-details { | |||||
| margin-left: 0; | |||||
| width: 100%; | |||||
| max-width: 450px; | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,197 @@ | |||||
| import * as React from "react"; | |||||
| import {useRef, useState} from "react"; | |||||
| import {NavLink, useNavigate} from "react-router-dom"; | |||||
| import {Button, IconButton, InputAdornment, TextField} from "@mui/material"; | |||||
| import {useAuth} from "../services/authentication/use-auth"; | |||||
| import {useInstances} from "../services/instances/use-instances"; | |||||
| import {Instance} from "../models/instance"; | |||||
| import { | |||||
| AddAPhoto, AddCircle, | |||||
| ArrowRightAltOutlined, RemoveCircle | |||||
| } from "@mui/icons-material"; | |||||
| import "./instance-selector-page.scss"; | |||||
| import DragAndDrop from "../components/drag-and-drop/drag-and-drop"; | |||||
| export function InstanceSelectorPage() { | |||||
| const navigate = useNavigate(); | |||||
| const { | |||||
| instances, | |||||
| createInstance, | |||||
| deleteInstance, | |||||
| updateInstance, | |||||
| uploadCoverPhoto, | |||||
| instanceApiError | |||||
| } = useInstances(); | |||||
| const [instancesToUpdate, setInstancesToUpdate] = useState(instances?.map(inst => new Instance(inst)) || []); | |||||
| const {userData} = useAuth(); | |||||
| const [newInstanceTitle, setNewInstanceTitle] = useState(""); | |||||
| // useEffect(() => { | |||||
| // setInstancesToUpdate(instances); | |||||
| // }, [instances]); | |||||
| let onNewInstanceTitleChanged = (e) => { | |||||
| setNewInstanceTitle(e.target.value); | |||||
| } | |||||
| const handleInstanceTitleChange = (e, idx) => { | |||||
| instancesToUpdate[idx].title = e.target.value; | |||||
| setInstancesToUpdate([...instancesToUpdate]); | |||||
| } | |||||
| const handleInstanceSubtitleChange = (e, idx) => { | |||||
| instancesToUpdate[idx].subtitle = e.target.value; | |||||
| setInstancesToUpdate([...instancesToUpdate]); | |||||
| } | |||||
| const handleNewInstanceUrlChange = (e, idx) => { | |||||
| instancesToUpdate[idx].newInstanceUrl = e.target.value; | |||||
| setInstancesToUpdate([...instancesToUpdate]); | |||||
| } | |||||
| const handleAddNewInstanceUrl = (e, idx) => { | |||||
| instancesToUpdate[idx].urls.push(instancesToUpdate[idx].newInstanceUrl); | |||||
| instancesToUpdate[idx].newInstanceUrl = ""; | |||||
| setInstancesToUpdate([...instancesToUpdate]); | |||||
| } | |||||
| const handleDeleteUrl = (e, idx, url) => { | |||||
| instancesToUpdate[idx].urls = instancesToUpdate[idx].urls.filter(u => u !== url); | |||||
| setInstancesToUpdate([...instancesToUpdate]); | |||||
| } | |||||
| const instanceHasChanges = (idx) => { | |||||
| return (instancesToUpdate[idx].title || "") !== (instances[idx].title || "") || | |||||
| (instancesToUpdate[idx].subtitle || "") !== (instances[idx].subtitle || "") || | |||||
| JSON.stringify(instancesToUpdate[idx].urls) !== JSON.stringify(instances[idx].urls); | |||||
| } | |||||
| const handleCreateInstance = async () => { | |||||
| await createInstance(new Instance({ | |||||
| title: newInstanceTitle | |||||
| })); | |||||
| } | |||||
| const handleSaveInstance = async (idx) => { | |||||
| if (instanceHasChanges(idx)) { | |||||
| instancesToUpdate[idx] = await updateInstance(instancesToUpdate[idx]); | |||||
| setInstancesToUpdate([...instancesToUpdate]); | |||||
| } | |||||
| } | |||||
| const handleDeleteInstance = async (e, instanceId) => { | |||||
| await deleteInstance(instanceId); | |||||
| } | |||||
| const [isUploading, setIsUploading] = useState({}); | |||||
| const fileInputRefs = useRef([]); | |||||
| const handleFiles = async (event, idx) => { | |||||
| setIsUploading({...isUploading, [idx]: true}); | |||||
| const formData = new FormData(); | |||||
| formData.append('cover-photo', event.target.files[0]); | |||||
| await uploadCoverPhoto(instancesToUpdate[idx].instanceId, formData); | |||||
| setIsUploading({...isUploading, [idx]: false}); | |||||
| } | |||||
| const handleDrop = async (e, idx) => { | |||||
| await handleFiles(e, idx) | |||||
| } | |||||
| return <div id="instance-selector-page"> | |||||
| {instanceApiError && <div className="error-box">{instanceApiError}</div>} | |||||
| <div> | |||||
| {userData?.profile?.resource_access?.photos?.roles?.indexOf("admin") >= 0 && <> | |||||
| <div id="edit-title" className="input-element"> | |||||
| <TextField fullWidth={true} variant="standard" | |||||
| value={newInstanceTitle} | |||||
| placeholder="Instance..." | |||||
| onChange={onNewInstanceTitleChanged} | |||||
| /> | |||||
| </div> | |||||
| <div className="input-buttons"> | |||||
| <div className="right-buttons"> | |||||
| <Button className="save" variant="contained" color="info" | |||||
| onClick={handleCreateInstance}> | |||||
| Create instance | |||||
| </Button> | |||||
| </div> | |||||
| </div> | |||||
| </>} | |||||
| </div> | |||||
| <div id="instance-list"> | |||||
| {instancesToUpdate?.map((instance, idx) => | |||||
| <div className="card-layout instance" key={instance.instanceId}> | |||||
| <div | |||||
| className={`${idx} cover-photo ${(!isUploading[idx] && instance.coverPhoto ? "" : "cover-photo--no-img")}`}> | |||||
| <DragAndDrop onDrop={(e) => handleDrop(e, idx)} | |||||
| onClick={(e) => fileInputRefs.current[idx].click(e)} | |||||
| isLoading={isUploading[idx]}> | |||||
| {!isUploading[idx] && instance.coverPhoto | |||||
| ? <img src={`${instance.coverPhoto.url}?width=450&height=450&fit=cover`} | |||||
| alt={instance.coverPhoto.name}/> | |||||
| : <AddAPhoto className="no-photos"/> | |||||
| } | |||||
| </DragAndDrop> | |||||
| <input type="file" id={`photosUpload`} ref={(element) => fileInputRefs.current[idx] = element} | |||||
| accept="image/*" | |||||
| onChange={(e) => handleFiles(e, idx)}></input> | |||||
| </div> | |||||
| <div className="card-details"> | |||||
| <div className="title input-element"> | |||||
| <TextField fullWidth={true} variant="standard" | |||||
| value={instance.title} | |||||
| placeholder="Title..." | |||||
| onChange={(e) => handleInstanceTitleChange(e, idx)} | |||||
| /> | |||||
| </div> | |||||
| <div className="description input-element"> | |||||
| <TextField fullWidth={true} variant="standard" | |||||
| value={instance.subtitle || ""} | |||||
| placeholder="Subtitle..." | |||||
| onChange={(e) => handleInstanceSubtitleChange(e, idx)} | |||||
| /> | |||||
| </div> | |||||
| <div className="url input-element"> | |||||
| <TextField fullWidth={true} variant="standard" | |||||
| value={instance.newInstanceUrl || ""} | |||||
| placeholder="https://my-photos.be" | |||||
| onChange={(e) => handleNewInstanceUrlChange(e, idx)} | |||||
| InputProps={{ | |||||
| endAdornment: ( | |||||
| <InputAdornment position="end"> | |||||
| <IconButton color="warning" | |||||
| onClick={(e) => handleAddNewInstanceUrl(e, idx)}> | |||||
| <AddCircle/> | |||||
| </IconButton> | |||||
| </InputAdornment> | |||||
| ) | |||||
| }}/> | |||||
| <div className="instance-links"> | |||||
| {instance.urls.map(url => <div className="instance-link"> | |||||
| <IconButton color="error" onClick={(e) => handleDeleteUrl(e, idx, url)}> | |||||
| <RemoveCircle /> | |||||
| </IconButton> | |||||
| <NavLink to={url}>{url}</NavLink> | |||||
| </div>)} | |||||
| </div> | |||||
| </div> | |||||
| <div className="input-buttons"> | |||||
| <div className="left-buttons"> | |||||
| <Button className="photo-save" variant="contained" color="error" | |||||
| onClick={(e) => handleDeleteInstance(e, instance.instanceId)}> | |||||
| Delete | |||||
| </Button> | |||||
| <Button className="photo-save" variant="contained" color="info" | |||||
| disabled={!instanceHasChanges(idx)} | |||||
| onClick={() => handleSaveInstance(idx)}> | |||||
| Save | |||||
| </Button> | |||||
| </div> | |||||
| <div className="right-buttons"> | |||||
| <Button className="photo-save" variant="contained" color="info" | |||||
| onClick={() => navigate(`/${instance.instanceId}`)}> | |||||
| Albums <ArrowRightAltOutlined/> | |||||
| </Button> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| )} | |||||
| </div> | |||||
| </div>; | |||||
| } | |||||
| @@ -0,0 +1,46 @@ | |||||
| @import "../variables"; | |||||
| #instance-selector-page { | |||||
| #instance-list { | |||||
| margin-top: 25px; | |||||
| display: grid; | |||||
| grid-template-columns: 1fr; | |||||
| gap: 25px; | |||||
| > .instance { | |||||
| .title { | |||||
| display: flex; | |||||
| .MuiFormControl-root { | |||||
| margin-right: 50px; | |||||
| } | |||||
| .MuiInput-input { | |||||
| font-size: 1.4rem; | |||||
| } | |||||
| .MuiButtonBase-root { | |||||
| flex: 0 0 100px; | |||||
| } | |||||
| } | |||||
| .instance-links { | |||||
| margin-top: 10px; | |||||
| } | |||||
| div.instance-link { | |||||
| padding: 2px 0; | |||||
| a { | |||||
| color: $accentColor; | |||||
| text-decoration: none; | |||||
| } | |||||
| } | |||||
| @media(max-width: 968px) { | |||||
| flex-direction: column; | |||||
| align-items: center; | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,82 @@ | |||||
| import {Button, TextField} from "@mui/material"; | |||||
| import * as React from "react"; | |||||
| import {useState} from "react"; | |||||
| import useInstancesApi from "../services/instances/instances-api"; | |||||
| import {useInstances} from "../services/instances/use-instances"; | |||||
| export function ManageInstancePage() { | |||||
| const {selectedInstance, setSelectedInstance} = useInstances(); | |||||
| const {updateInstance} = useInstancesApi(); | |||||
| let [instanceToUpdate, setInstanceToUpdate] = useState({}); | |||||
| const [apiError, setApiError] = useState(null); | |||||
| const handleTitleChange = (e) => { | |||||
| instanceToUpdate.title = e.target.value; | |||||
| setInstanceToUpdate(instanceToUpdate); | |||||
| } | |||||
| const handleSubtitleChange = (e) => { | |||||
| instanceToUpdate.subtitle = e.target.value; | |||||
| setInstanceToUpdate(instanceToUpdate); | |||||
| } | |||||
| const instanceHasChanges = () => { | |||||
| return (instanceToUpdate.title || "") !== (selectedInstance.title || "") || | |||||
| (instanceToUpdate.subtitle || "") !== (selectedInstance.subtitle || ""); | |||||
| } | |||||
| const handleSaveInstance = async () => { | |||||
| try { | |||||
| setApiError(null); | |||||
| if (instanceHasChanges()) { | |||||
| const updatedInstance = await updateInstance( | |||||
| instanceToUpdate | |||||
| ); | |||||
| setSelectedInstance(updatedInstance); | |||||
| setInstanceToUpdate(updatedInstance); | |||||
| } | |||||
| } catch (err) { | |||||
| console.error(err); | |||||
| setApiError("An error occurred trying to save the instance, contact the system administrator"); | |||||
| } | |||||
| } | |||||
| return <> | |||||
| <div className="card-layout photo"> | |||||
| {apiError && <div className="error-box">{apiError}</div>} | |||||
| {selectedInstance && <> | |||||
| {selectedInstance.photo && | |||||
| <div className="cover-photo"> | |||||
| {<img src={`${selectedInstance.photo.url}?width=450&height=450&fit=cover`} | |||||
| alt={selectedInstance.photo.name}/>} | |||||
| <div className="file-name"> | |||||
| {selectedInstance.photo.fileName} | |||||
| </div> | |||||
| </div> | |||||
| } | |||||
| <div className="card-details"> | |||||
| <div className="title input-element"> | |||||
| <TextField fullWidth={true} variant="standard" | |||||
| value={selectedInstance.title} | |||||
| onChange={handleTitleChange} | |||||
| /> | |||||
| </div> | |||||
| <div className="description input-element"> | |||||
| <TextField multiline={true} fullWidth={true} rows={8} | |||||
| onChange={handleSubtitleChange} | |||||
| value={selectedInstance.subtitle || ""} | |||||
| /> | |||||
| </div> | |||||
| <div className="input-buttons"> | |||||
| <div className="left-buttons"> | |||||
| <Button className="photo-save" variant="contained" color="info" | |||||
| disabled={!instanceHasChanges()} | |||||
| onClick={handleSaveInstance}> | |||||
| Save | |||||
| </Button> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| </>} | |||||
| </div> | |||||
| </>; | |||||
| } | |||||
| @@ -0,0 +1,3 @@ | |||||
| export function NoAccessPage() { | |||||
| return <h1>No access!</h1> | |||||
| } | |||||
| @@ -0,0 +1,210 @@ | |||||
| import * as React from "react"; | |||||
| import {useCallback, useEffect, useRef, useState} from "react"; | |||||
| import useAlbumsApi from "../services/albums-api"; | |||||
| import { | |||||
| AddAPhoto, | |||||
| KeyboardArrowRight, | |||||
| MoveDown, | |||||
| MoveUp | |||||
| } from "@mui/icons-material"; | |||||
| import {Link, useNavigate} from "react-router-dom"; | |||||
| import Loader from "../components/Loader"; | |||||
| import {Button, TextField} from "@mui/material"; | |||||
| import {Album} from "../models/album"; | |||||
| import "./portfolio-manager.scss" | |||||
| import {useInstances} from "../services/instances/use-instances"; | |||||
| function PortfolioManager() { | |||||
| const navigate = useNavigate(); | |||||
| const {selectedInstance} = useInstances(); | |||||
| const [apiError, setApiError] = useState(null); | |||||
| const {fetchAlbums, patchAlbum, deleteAlbum, moveAlbumDown, moveAlbumUp} = useAlbumsApi(); | |||||
| const [albums, setAlbums] = useState(null); | |||||
| const [isLoading, setIsLoading] = useState(false); | |||||
| const [albumsToUpdate, setAlbumsToUpdate] = useState(null); | |||||
| const fetchedRef = useRef(false) | |||||
| const fetchData = useCallback(async () => { | |||||
| try { | |||||
| setIsLoading(true); | |||||
| setApiError(null); | |||||
| const albums = await fetchAlbums(selectedInstance); | |||||
| setAlbums(albums); | |||||
| setAlbumsToUpdate(albums.map(a => new Album(a))); | |||||
| } catch (err) { | |||||
| console.error("Could not fetch albums", err); | |||||
| setApiError("An error occurred trying to fetch your albums, please contact the system administrator"); | |||||
| setAlbums([]); | |||||
| } finally { | |||||
| setIsLoading(false); | |||||
| } | |||||
| }, [fetchAlbums, selectedInstance]) | |||||
| useEffect(() => { | |||||
| if (selectedInstance && !fetchedRef.current) { | |||||
| fetchData(); | |||||
| fetchedRef.current = true; | |||||
| } | |||||
| }, [selectedInstance, fetchData]); | |||||
| const handleAlbumNameChange = (albumIndex, e) => { | |||||
| albumsToUpdate[albumIndex].name = e.target.value; | |||||
| setAlbumsToUpdate([...albumsToUpdate]); | |||||
| } | |||||
| const handleAlbumDescriptionChange = (albumIndex, e) => { | |||||
| albumsToUpdate[albumIndex].description = e.target.value; | |||||
| setAlbumsToUpdate([...albumsToUpdate]); | |||||
| } | |||||
| const albumHasChanges = (idx) => { | |||||
| return albumsToUpdate[idx].name !== albums[idx].name | |||||
| || albumsToUpdate[idx].description !== albums[idx].description; | |||||
| } | |||||
| const handleSaveAlbum = async (idx) => { | |||||
| try { | |||||
| setIsLoading(true); | |||||
| if (albumHasChanges(idx)) { | |||||
| albumsToUpdate[idx] = await patchAlbum(selectedInstance, albumsToUpdate[idx]); | |||||
| setAlbums([ | |||||
| ...albumsToUpdate.map(a => new Album(a)) | |||||
| ]); | |||||
| } | |||||
| } catch (err) { | |||||
| if (typeof err === "string" && err.toLowerCase().indexOf("duplicate") >= 0) { | |||||
| setApiError("Name already in use"); | |||||
| } else { | |||||
| console.error(err); | |||||
| navigate(`/${selectedInstance.instanceId}/albums`, {state: {error: err}}); | |||||
| } | |||||
| } finally { | |||||
| setIsLoading(false); | |||||
| } | |||||
| } | |||||
| const handleDeleteAlbum = async (e, albumId) => { | |||||
| try { | |||||
| setIsLoading(true); | |||||
| const updatedAlbums = await deleteAlbum(selectedInstance, albumId); | |||||
| setAlbums(updatedAlbums); | |||||
| setAlbumsToUpdate(albumsToUpdate.filter(a => a.albumId !== albumId)); | |||||
| } catch (err) { | |||||
| console.error(err); | |||||
| } finally { | |||||
| setIsLoading(false); | |||||
| } | |||||
| } | |||||
| const handleMoveAlbumDown = async (idx) => { | |||||
| try { | |||||
| setIsLoading(true); | |||||
| const updatedAlbums = await moveAlbumDown(selectedInstance, albumsToUpdate[idx].albumId); | |||||
| const origAlbum = albumsToUpdate[idx]; | |||||
| albumsToUpdate[idx] = albumsToUpdate[idx + 1]; | |||||
| albumsToUpdate[idx + 1] = origAlbum; | |||||
| setAlbums(updatedAlbums); | |||||
| } catch (err) { | |||||
| console.error(err); | |||||
| } finally { | |||||
| setIsLoading(false); | |||||
| } | |||||
| } | |||||
| const handleMoveAlbumUp = async (idx) => { | |||||
| try { | |||||
| setIsLoading(true); | |||||
| const updatedAlbums = await moveAlbumUp(selectedInstance, albumsToUpdate[idx].albumId); | |||||
| const origAlbum = albumsToUpdate[idx]; | |||||
| albumsToUpdate[idx] = albumsToUpdate[idx - 1]; | |||||
| albumsToUpdate[idx - 1] = origAlbum; | |||||
| setAlbums(updatedAlbums); | |||||
| } catch (err) { | |||||
| console.error(err); | |||||
| } finally { | |||||
| setIsLoading(false); | |||||
| } | |||||
| } | |||||
| return <div id="portfolio-manager"> | |||||
| <div id="page-title"> | |||||
| <div id="left-part"> | |||||
| <div className="title">Albums</div> | |||||
| {albums && <div className="photos-count">({albums.length} albums)</div>} | |||||
| </div> | |||||
| <div id="right-part"> | |||||
| <Button id="page-save" variant="contained" color="success" | |||||
| onClick={() => navigate("new", {state: {album: new Album()}})}> | |||||
| Create | |||||
| </Button> | |||||
| </div> | |||||
| </div> | |||||
| {apiError && <div className="error-box">{apiError}</div>} | |||||
| {!apiError && albums?.length > 0 && <div id="album-list"> | |||||
| {albumsToUpdate.map((album, idx) => | |||||
| <div className="card-layout album-list-item" key={album.albumId}> | |||||
| <div className={`cover-photo ${(album.coverPhoto ? "" : "cover-photo--no-img")}`} | |||||
| onClick={() => navigate(album.albumId, {state: {album: albums[idx]}})}> | |||||
| {album.coverPhoto | |||||
| ? <img src={`${album.coverPhoto.url}?width=450&height=450&fit=cover`} | |||||
| alt={album.coverPhoto.name}/> | |||||
| : <AddAPhoto className="no-photos"/> | |||||
| } | |||||
| </div> | |||||
| <div className="card-details"> | |||||
| <div className="title input-element"> | |||||
| <TextField fullWidth={true} variant="standard" | |||||
| value={album.name} onChange={(e) => handleAlbumNameChange(idx, e)} | |||||
| /> | |||||
| <Button endIcon={<KeyboardArrowRight/>} variant="contained" | |||||
| color="secondary" | |||||
| onClick={() => navigate(album.albumId, {state: {album: albums[idx]}})}> | |||||
| Photos | |||||
| </Button> | |||||
| </div> | |||||
| <div className="description input-element"> | |||||
| <TextField multiline={true} fullWidth={true} rows={8} | |||||
| onChange={(e) => handleAlbumDescriptionChange(idx, e)} | |||||
| value={album.description || ""} | |||||
| /> | |||||
| </div> | |||||
| <div className="input-buttons"> | |||||
| <div className="left-buttons"> | |||||
| <Button className="photo-save" variant="contained" color="error" | |||||
| onClick={(e) => handleDeleteAlbum(e, album.albumId)}> | |||||
| Delete | |||||
| </Button> | |||||
| <Button className="photo-save" variant="contained" color="info" disabled={!albumHasChanges(idx)} | |||||
| onClick={() => handleSaveAlbum(idx)}> | |||||
| Save | |||||
| </Button> | |||||
| </div> | |||||
| <div className="right-buttons"> | |||||
| <Button | |||||
| style={{visibility: (idx > 0 ? "visible" : "hidden")}} | |||||
| startIcon={<MoveUp/>} variant="contained" color="action" | |||||
| onClick={() => handleMoveAlbumUp(idx)}> | |||||
| Move up | |||||
| </Button> | |||||
| <Button | |||||
| style={{visibility: (idx < albumsToUpdate.length - 1 ? "visible" : "hidden")}} | |||||
| startIcon={<MoveDown/>} variant="contained" color="action" | |||||
| onClick={(e) => handleMoveAlbumDown(idx)}> | |||||
| Move down | |||||
| </Button> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| )} | |||||
| </div>} | |||||
| {!apiError && albums?.length === 0 && <div><Link to="new" state={{album: new Album()}}>Click here</Link> to create your first album!</div>} | |||||
| <Loader loading={isLoading} /> | |||||
| </div> | |||||
| } | |||||
| export default PortfolioManager; | |||||
| @@ -0,0 +1,41 @@ | |||||
| @import "../variables"; | |||||
| #portfolio-manager { | |||||
| #page-title { | |||||
| #left-part { | |||||
| align-items: first baseline; | |||||
| } | |||||
| } | |||||
| #album-list { | |||||
| margin-top: 25px; | |||||
| display: grid; | |||||
| grid-template-columns: 1fr; | |||||
| gap: 25px; | |||||
| > .album-list-item { | |||||
| .title { | |||||
| display: flex; | |||||
| .MuiFormControl-root { | |||||
| margin-right: 50px; | |||||
| } | |||||
| .MuiInput-input { | |||||
| font-size: 1.4rem; | |||||
| } | |||||
| .MuiButtonBase-root { | |||||
| flex: 0 0 100px; | |||||
| } | |||||
| } | |||||
| @media(max-width: 968px) { | |||||
| flex-direction: column; | |||||
| align-items: center; | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,134 @@ | |||||
| import useAuthorizedApi from "./authorized-api"; | |||||
| import {Album} from "../models/album"; | |||||
| const apiUrl = window._env_.REACT_APP_API_URL; | |||||
| export default function useAlbumsApi() { | |||||
| const {get, post, patch, doDelete} = useAuthorizedApi(); | |||||
| return { | |||||
| fetchAlbums, | |||||
| postAlbum, | |||||
| patchAlbum, | |||||
| deleteAlbum, | |||||
| moveAlbumDown, | |||||
| moveAlbumUp, | |||||
| patchPhoto, | |||||
| uploadPhotos, | |||||
| deletePhoto, | |||||
| movePhotoDown, | |||||
| movePhotoUp, | |||||
| }; | |||||
| /** | |||||
| * @return {Promise<Album[]>} | |||||
| */ | |||||
| async function fetchAlbums(instance) { | |||||
| const response = await get(`${apiUrl}/api/instances/${instance.instanceId}/albums`); | |||||
| return response.data.map(album => new Album(album)); | |||||
| } | |||||
| /** | |||||
| * @param {Instance} instance | |||||
| * @param {Album} album | |||||
| * @return {Promise<Album>} | |||||
| */ | |||||
| async function patchAlbum(instance, album) { | |||||
| const patches = []; | |||||
| if (album.name) { | |||||
| patches.push({op: "replace", path: "/name", value: album.name}) | |||||
| } | |||||
| if (album.description) { | |||||
| patches.push({op: "replace", path: "/description", value: album.description}) | |||||
| } | |||||
| if (album.sort) { | |||||
| patches.push({op: "replace", path: "/sort", value: album.sort}) | |||||
| } | |||||
| if (patches.length === 0) { | |||||
| return null; | |||||
| } | |||||
| const response = await patch( | |||||
| `${apiUrl}/api/instances/${instance.instanceId}/albums/${album.albumId}`, | |||||
| patches | |||||
| ); | |||||
| return new Album(response.data); | |||||
| } | |||||
| /** | |||||
| * @param {Instance} instance | |||||
| * @param {Album} album | |||||
| * @return {Promise<Album>} | |||||
| */ | |||||
| async function postAlbum(instance, album) { | |||||
| const response = await post( | |||||
| `${apiUrl}/api/instances/${instance.instanceId}/albums`, { | |||||
| name: album.name, | |||||
| description: album.description, | |||||
| sort: album.sort | |||||
| } | |||||
| ); | |||||
| return new Album(response.data); | |||||
| } | |||||
| async function deleteAlbum(instance, albumId) { | |||||
| const response = await doDelete(`${apiUrl}/api/instances/${instance.instanceId}/albums/${albumId}`); | |||||
| return response.data.map(a => new Album(a)); | |||||
| } | |||||
| async function moveAlbumDown(instance, albumId) { | |||||
| const response = await post(`${apiUrl}/api/instances/${instance.instanceId}/albums/${albumId}/move-down`); | |||||
| return response.data.map(a => new Album(a)); | |||||
| } | |||||
| async function moveAlbumUp(instance, albumId) { | |||||
| const response = await post(`${apiUrl}/api/instances/${instance.instanceId}/albums/${albumId}/move-up`); | |||||
| return response.data.map(a => new Album(a)); | |||||
| } | |||||
| /** | |||||
| * @param {Instance} instance | |||||
| * @param {string} albumId | |||||
| * @param {Photo} photo | |||||
| * @return {Promise<Album>} | |||||
| */ | |||||
| async function patchPhoto(instance, albumId, photo) { | |||||
| const patches = []; | |||||
| if (photo.name) { | |||||
| patches.push({op: "replace", path: "/name", value: photo.name}) | |||||
| } | |||||
| if (photo.description) { | |||||
| patches.push({op: "replace", path: "/description", value: photo.description}) | |||||
| } | |||||
| if (patches.length === 0) { | |||||
| return null; | |||||
| } | |||||
| const response = await patch( | |||||
| `${apiUrl}/api/instances/${instance.instanceId}/albums/${albumId}/photos/${photo.photoId}`, | |||||
| patches | |||||
| ); | |||||
| return new Album(response.data); | |||||
| } | |||||
| async function uploadPhotos(instance, albumId, formData) { | |||||
| const response = await post( | |||||
| `${apiUrl}/api/instances/${instance.instanceId}/albums/${albumId}/upload-photos`, | |||||
| formData | |||||
| ); | |||||
| return new Album(response.data); | |||||
| } | |||||
| async function deletePhoto(instance, albumId, photoId) { | |||||
| const response = await doDelete(`${apiUrl}/api/instances/${instance.instanceId}/albums/${albumId}/photos/${photoId}`); | |||||
| return new Album(response.data); | |||||
| } | |||||
| async function movePhotoDown(instance, albumId, photoId) { | |||||
| const response = await post(`${apiUrl}/api/instances/${instance.instanceId}/albums/${albumId}/photos/${photoId}/move-down`); | |||||
| return new Album(response.data); | |||||
| } | |||||
| async function movePhotoUp(instance, albumId, photoId) { | |||||
| const response = await post(`${apiUrl}/api/instances/${instance.instanceId}/albums/${albumId}/photos/${photoId}/move-up`); | |||||
| return new Album(response.data); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,236 @@ | |||||
| import React, { | |||||
| useState, | |||||
| useEffect, | |||||
| useRef, | |||||
| useMemo, | |||||
| useCallback, | |||||
| } from 'react'; | |||||
| import { | |||||
| UserManager, | |||||
| } from 'oidc-client-ts'; | |||||
| export const AuthContext = React.createContext( | |||||
| undefined | |||||
| ); | |||||
| /** | |||||
| * @private | |||||
| * @hidden | |||||
| * @param {Location} location | |||||
| */ | |||||
| export const hasCodeInUrl = (location) => { | |||||
| const searchParams = new URLSearchParams(location.search); | |||||
| const hashParams = new URLSearchParams(location.hash.replace('#', '?')); | |||||
| return ( | |||||
| searchParams.has('code') ?? | |||||
| searchParams.has('id_token') ?? | |||||
| searchParams.has('session_state') ?? | |||||
| hashParams.has('code') ?? | |||||
| hashParams.has('id_token') ?? | |||||
| hashParams.has('session_state') | |||||
| ); | |||||
| }; | |||||
| /** | |||||
| * @private | |||||
| * @hidden | |||||
| * @param props | |||||
| */ | |||||
| export const initUserManager = (props) => { | |||||
| if (props.userManager) { | |||||
| return props.userManager; | |||||
| } | |||||
| const { | |||||
| authority, | |||||
| clientId, | |||||
| clientSecret, | |||||
| redirectUri, | |||||
| silentRedirectUri, | |||||
| postLogoutRedirectUri, | |||||
| responseType, | |||||
| scope, | |||||
| automaticSilentRenew, | |||||
| loadUserInfo, | |||||
| popupWindowFeatures, | |||||
| popupRedirectUri, | |||||
| popupWindowTarget, | |||||
| extraQueryParams, | |||||
| metadata, | |||||
| } = props; | |||||
| return new UserManager({ | |||||
| authority: authority ?? '', | |||||
| client_id: clientId ?? '', | |||||
| client_secret: clientSecret, | |||||
| redirect_uri: redirectUri ?? '', | |||||
| silent_redirect_uri: silentRedirectUri ?? redirectUri, | |||||
| post_logout_redirect_uri: postLogoutRedirectUri ?? redirectUri, | |||||
| response_type: responseType ?? 'code', | |||||
| scope: scope ?? 'authentication', | |||||
| loadUserInfo: loadUserInfo ?? true, | |||||
| popupWindowFeatures: popupWindowFeatures, | |||||
| popup_redirect_uri: popupRedirectUri, | |||||
| popupWindowTarget: popupWindowTarget, | |||||
| automaticSilentRenew, | |||||
| extraQueryParams, | |||||
| metadata: metadata, | |||||
| }); | |||||
| }; | |||||
| /** | |||||
| * | |||||
| * @param children | |||||
| * @param autoSignIn | |||||
| * @param autoSignInArgs | |||||
| * @param autoSignOut | |||||
| * @param autoSignOutArgs | |||||
| * @param onBeforeSignIn | |||||
| * @param onSignIn | |||||
| * @param onSignOut | |||||
| * @param location | |||||
| * @param onSignInError | |||||
| * @param props AuthProviderProps | |||||
| */ | |||||
| export const AuthProvider = ({ | |||||
| children, | |||||
| autoSignIn = true, | |||||
| autoSignInArgs, | |||||
| autoSignOut = true, | |||||
| autoSignOutArgs, | |||||
| onBeforeSignIn, | |||||
| onSignIn, | |||||
| onSignOut, | |||||
| location = window.location, | |||||
| onSignInError, | |||||
| ...props | |||||
| }) => { | |||||
| const [isLoading, setIsLoading] = useState(true); | |||||
| const [userData, setUserData] = useState(null); | |||||
| const [userManager] = useState(() => initUserManager(props)); | |||||
| const isMountedRef = useRef(false); | |||||
| const signOutHooks = useCallback(async ()=> { | |||||
| setUserData(null); | |||||
| if (onSignOut) { | |||||
| await onSignOut(); | |||||
| } | |||||
| }, [onSignOut]); | |||||
| const signInPopupHooks = useCallback(async ()=> { | |||||
| const userFromPopup = await userManager.signinPopup(); | |||||
| setUserData(userFromPopup); | |||||
| if (onSignIn) { | |||||
| await onSignIn(userFromPopup); | |||||
| } | |||||
| await userManager.signinPopupCallback(); | |||||
| }, [userManager, onSignIn]); | |||||
| /** | |||||
| * Handles user auth flow on initial render. | |||||
| */ | |||||
| useEffect(() => { | |||||
| let isMounted = true; | |||||
| isMountedRef.current = true; | |||||
| setIsLoading(true); | |||||
| void (async () => { | |||||
| if (!userManager) { | |||||
| return; | |||||
| } | |||||
| const user = await userManager.getUser(); | |||||
| // isMountedRef cannot be used here as its value is updated by next useEffect. | |||||
| // We intend to keep context of current useEffect. | |||||
| if (isMounted && (!user || user.expired)) { | |||||
| // If the user is returning back from the OIDC provider, get and set the user data. | |||||
| if (hasCodeInUrl(location)) { | |||||
| try { | |||||
| const user = await userManager.signinCallback(); | |||||
| if (user) { | |||||
| setUserData(user); | |||||
| if (onSignIn) { | |||||
| await onSignIn(user); | |||||
| } | |||||
| } | |||||
| } catch (error) { | |||||
| if (onSignInError) { | |||||
| onSignInError(error); | |||||
| } else { | |||||
| throw error; | |||||
| } | |||||
| } | |||||
| } | |||||
| // If autoSignIn is enabled, redirect to the OIDC provider. | |||||
| else if (autoSignIn) { | |||||
| const state = onBeforeSignIn ? onBeforeSignIn() : undefined; | |||||
| await userManager.signinRedirect({ ...autoSignInArgs, state }); | |||||
| } | |||||
| } | |||||
| // Otherwise if the user is already signed in, set the user data. | |||||
| else if (isMountedRef.current) { | |||||
| setUserData(user); | |||||
| } | |||||
| setIsLoading(false); | |||||
| })(); | |||||
| return () => { | |||||
| isMounted = false; | |||||
| isMountedRef.current = false; | |||||
| }; | |||||
| }, [ | |||||
| location, | |||||
| userManager, | |||||
| autoSignIn, | |||||
| onBeforeSignIn, | |||||
| onSignIn, | |||||
| onSignInError, | |||||
| autoSignInArgs, | |||||
| ]); | |||||
| /** | |||||
| * Registers UserManager event callbacks for handling changes to user state due to automaticSilentRenew, session expiry, etc. | |||||
| */ | |||||
| useEffect(() => { | |||||
| const updateUserData = (user) => { | |||||
| if (isMountedRef.current) { | |||||
| setUserData(user); | |||||
| } | |||||
| }; | |||||
| const onSilentRenewError = | |||||
| async () => { | |||||
| if (autoSignOut) { | |||||
| await signOutHooks(); | |||||
| await userManager.signoutRedirect(autoSignOutArgs); | |||||
| } | |||||
| }; | |||||
| userManager.events.addUserLoaded(updateUserData); | |||||
| userManager.events.addSilentRenewError(onSilentRenewError); | |||||
| return () => { | |||||
| userManager.events.removeUserLoaded(updateUserData); | |||||
| userManager.events.removeSilentRenewError(onSilentRenewError); | |||||
| }; | |||||
| }, [userManager, autoSignOut, signOutHooks, autoSignOutArgs]); | |||||
| const value = useMemo(() => { | |||||
| return { | |||||
| signIn: async (args) => { | |||||
| await userManager.signinRedirect(args); | |||||
| }, | |||||
| signInPopup: async () => { | |||||
| await signInPopupHooks(); | |||||
| }, | |||||
| signOut: async ()=> { | |||||
| await userManager.removeUser(); | |||||
| await signOutHooks(); | |||||
| }, | |||||
| signOutRedirect: async (args) => { | |||||
| await userManager.signoutRedirect(args); | |||||
| await signOutHooks(); | |||||
| }, | |||||
| userManager, | |||||
| userData, | |||||
| isLoading, | |||||
| }; | |||||
| }, [userManager, isLoading, userData, signInPopupHooks, signOutHooks]); | |||||
| return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>; | |||||
| }; | |||||
| @@ -0,0 +1,17 @@ | |||||
| import {AuthProvider} from "./auth-context"; | |||||
| function keycloak({children}) { | |||||
| return <AuthProvider | |||||
| authority="https://keycloak.novox.be/realms/Novox" | |||||
| autoSignIn={false} | |||||
| scope="openid roles" | |||||
| loadUserInfo={true} | |||||
| clientId="photos" | |||||
| silentRedirectUri={`${window.location.protocol}//${window.location.host}/admin/signin-keycloak`} | |||||
| redirectUri={`${window.location.protocol}//${window.location.host}/admin/signin-keycloak`} | |||||
| postLogoutRedirectUri={`${window.location.protocol}//${window.location.host}/admin/signout-keycloak`} | |||||
| > | |||||
| {children} | |||||
| </AuthProvider>; | |||||
| } | |||||
| export default keycloak; | |||||
| @@ -0,0 +1,14 @@ | |||||
| import { useContext } from 'react'; | |||||
| import { AuthContext } from './auth-context'; | |||||
| export const useAuth = () => { | |||||
| const context = useContext(AuthContext); | |||||
| if (!context) { | |||||
| throw new Error( | |||||
| 'AuthProvider context is undefined, please verify you are calling useAuth() as child of a <AuthProvider> component.', | |||||
| ); | |||||
| } | |||||
| return context; | |||||
| }; | |||||
| @@ -0,0 +1,69 @@ | |||||
| import {useAuth} from "./authentication/use-auth"; | |||||
| export default function useAuthorizedApi() { | |||||
| const {userData, signOut} = useAuth(); | |||||
| return { | |||||
| get: request('GET'), | |||||
| post: request('POST'), | |||||
| put: request('PUT'), | |||||
| patch: request('PATCH'), | |||||
| doDelete: request('DELETE') | |||||
| }; | |||||
| /** | |||||
| * @param {string} method | |||||
| * @return {function(*, *): Promise<*>} | |||||
| */ | |||||
| function request(method) { | |||||
| /** | |||||
| * @param {string} url | |||||
| * @param {?any} [body] | |||||
| */ | |||||
| return async (url, body) => { | |||||
| /** @type {RequestInit} **/ | |||||
| const requestOptions = { | |||||
| method, | |||||
| headers: authHeader(url) | |||||
| }; | |||||
| if (body) { | |||||
| if (body instanceof FormData) { | |||||
| requestOptions.body = body; | |||||
| } else { | |||||
| requestOptions.headers['Content-Type'] = 'application/json'; | |||||
| requestOptions.body = JSON.stringify(body); | |||||
| } | |||||
| } | |||||
| return handleResponse(await fetch(url, requestOptions)); | |||||
| } | |||||
| } | |||||
| function authHeader(url) { | |||||
| // return auth header with jwt if user is logged in and request is to the api url | |||||
| const token = userData?.access_token; | |||||
| const isLoggedIn = !!token; | |||||
| const isApiUrl = url.startsWith(window._env_.REACT_APP_API_URL); | |||||
| if (isLoggedIn && isApiUrl) { | |||||
| return {Authorization: `Bearer ${token}`}; | |||||
| } else { | |||||
| return {}; | |||||
| } | |||||
| } | |||||
| async function handleResponse(response) { | |||||
| const text = await response.text() | |||||
| const data = text && JSON.parse(text); | |||||
| if (!response.ok) { | |||||
| if ([401, 403].includes(response.status) && userData?.access_token) { | |||||
| // auto logout if 401 Unauthorized or 403 Forbidden response returned from api | |||||
| localStorage.removeItem('user'); | |||||
| await signOut(); | |||||
| } | |||||
| const error = (data && data.message) || response.statusText; | |||||
| return Promise.reject(error); | |||||
| } | |||||
| return data; | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,63 @@ | |||||
| import useAuthorizedApi from "../authorized-api"; | |||||
| import {Instance} from "../../models/instance"; | |||||
| import {useState} from "react"; | |||||
| const apiUrl = window._env_.REACT_APP_API_URL; | |||||
| export default function useInstancesApi() { | |||||
| const {get, post, put, doDelete} = useAuthorizedApi(); | |||||
| const [instanceApiError, setInstanceApiError] = useState(null); | |||||
| const tryApiCall = async (func) => { | |||||
| try { | |||||
| return await func(); | |||||
| } catch (err) { | |||||
| console.error(err); | |||||
| if (typeof err === "string" && err.toLowerCase().indexOf("already exist") >= 0) { | |||||
| setInstanceApiError(err); | |||||
| } else if (typeof err === "string" && err.toLowerCase().indexOf("mimetype") >= 0) { | |||||
| setInstanceApiError(err); | |||||
| } else { | |||||
| setInstanceApiError("The API is currently unavailable, please contact the administrator"); | |||||
| } | |||||
| return null; | |||||
| } | |||||
| } | |||||
| const fetchInstances = async () => tryApiCall(async () => { | |||||
| const response = await get(`${apiUrl}/api/instances`); | |||||
| return response?.data.map(inst => new Instance(inst)); | |||||
| }); | |||||
| const updateInstance = async (instance) => tryApiCall(async () => { | |||||
| const response = await put(`${apiUrl}/api/instances/${instance.instanceId}`, instance); | |||||
| return new Instance(response.data); | |||||
| }); | |||||
| const deleteInstance = async (instanceId) => tryApiCall(async () => { | |||||
| const response = await doDelete(`${apiUrl}/api/instances/${instanceId}`); | |||||
| return new Instance(response.data); | |||||
| }); | |||||
| const createInstance = (instance) => tryApiCall(async () => { | |||||
| const response = await post(`${apiUrl}/api/instances`, instance); | |||||
| return new Instance(response.data); | |||||
| }); | |||||
| const uploadCoverPhoto = (instanceId, formData) => tryApiCall(async () => { | |||||
| const response = await post( | |||||
| `${apiUrl}/api/instances/${instanceId}/upload-cover-photo`, | |||||
| formData | |||||
| ); | |||||
| return new Instance(response.data); | |||||
| }); | |||||
| return { | |||||
| fetchInstances, | |||||
| updateInstance, | |||||
| deleteInstance, | |||||
| createInstance, | |||||
| uploadCoverPhoto, | |||||
| instanceApiError, | |||||
| }; | |||||
| } | |||||
| @@ -0,0 +1,78 @@ | |||||
| import React, {useEffect, useState} from "react"; | |||||
| import useInstancesApi from "./instances-api"; | |||||
| import {useAuth} from "../authentication/use-auth"; | |||||
| import {useLocation, useNavigate} from "react-router-dom"; | |||||
| import {Instance} from "../../models/instance"; | |||||
| export const InstancesContext = React.createContext( | |||||
| undefined | |||||
| ); | |||||
| export const InstancesProvider = ({children}) => { | |||||
| const navigate = useNavigate(); | |||||
| const {pathname} = useLocation(); | |||||
| const {userData} = useAuth(); | |||||
| const {fetchInstances, createInstance, deleteInstance, updateInstance, uploadCoverPhoto, instanceApiError} = useInstancesApi(); | |||||
| const [isLoading, setIsLoading] = useState(true); | |||||
| const [instances, setInstances] = useState(undefined); | |||||
| const [selectedInstance, setSelectedInstance] = useState(null); | |||||
| useEffect(() => { | |||||
| void (async () => { | |||||
| if (userData && fetchInstances) { | |||||
| if (instances === undefined) { | |||||
| setIsLoading(true); | |||||
| const instances = await fetchInstances(); | |||||
| setInstances(instances); | |||||
| setIsLoading(false); | |||||
| } else { | |||||
| const possibleSelectedInstance = pathname.split('/')[1]; | |||||
| if(possibleSelectedInstance){ | |||||
| const selectedInstance = instances?.find(inst => inst.instanceId === possibleSelectedInstance); | |||||
| if (selectedInstance) { | |||||
| setSelectedInstance(selectedInstance); | |||||
| } else { | |||||
| navigate("/"); | |||||
| } | |||||
| } else { | |||||
| setSelectedInstance(null); | |||||
| } | |||||
| } | |||||
| } | |||||
| })(); | |||||
| }, [navigate, pathname, userData, instances, fetchInstances]); | |||||
| const handleCreateInstance = async (instance) => { | |||||
| const newInstance = await createInstance(instance); | |||||
| setInstances([...instances, newInstance]); | |||||
| } | |||||
| const handleUpdateInstance = async (instance) => { | |||||
| const updatedInstance = await updateInstance(instance); | |||||
| const idx = instances.findIndex(i => i.instanceId === updatedInstance.instanceId); | |||||
| instances[idx] = updatedInstance; | |||||
| setInstances([...instances]); | |||||
| return updatedInstance; | |||||
| } | |||||
| const handleUpdateCoverPhoto = async (instanceId, formData) => { | |||||
| const updatedInstance = await uploadCoverPhoto(instanceId, formData); | |||||
| const idx = instances.findIndex(i => i.instanceId === updatedInstance.instanceId); | |||||
| instances[idx] = updatedInstance; | |||||
| setInstances(instances.map(i => new Instance(i))); | |||||
| return updatedInstance; | |||||
| } | |||||
| const value = { | |||||
| isLoading, | |||||
| instances, | |||||
| selectedInstance, | |||||
| setSelectedInstance, | |||||
| createInstance: handleCreateInstance, | |||||
| deleteInstance, | |||||
| updateInstance: handleUpdateInstance, | |||||
| uploadCoverPhoto: handleUpdateCoverPhoto, | |||||
| instanceApiError, | |||||
| }; | |||||
| return <InstancesContext.Provider value={value}>{children}</InstancesContext.Provider>; | |||||
| }; | |||||
| @@ -0,0 +1,17 @@ | |||||
| import {useContext} from "react"; | |||||
| import {InstancesContext} from "./instances-context"; | |||||
| /** | |||||
| * @return {InstancesContext} | |||||
| */ | |||||
| export const useInstances = () => { | |||||
| const context = useContext(InstancesContext); | |||||
| if (!context) { | |||||
| throw new Error( | |||||
| 'Instances context is undefined, please verify you are calling useInstances() as child of a <InstancesProvider> component.', | |||||
| ); | |||||
| } | |||||
| return context; | |||||
| }; | |||||
| @@ -0,0 +1,23 @@ | |||||
| export const createAnimation = (loaderName, frames, suffix) => { | |||||
| const animationName = `react-spinners-${loaderName}-${suffix}`; | |||||
| if (typeof window == "undefined" || !window.document) { | |||||
| return animationName; | |||||
| } | |||||
| const styleEl = document.createElement("style"); | |||||
| document.head.appendChild(styleEl); | |||||
| const styleSheet = styleEl.sheet; | |||||
| const keyFrames = ` | |||||
| @keyframes ${animationName} { | |||||
| ${frames} | |||||
| } | |||||
| `; | |||||
| if (styleSheet) { | |||||
| styleSheet.insertRule(keyFrames, 0); | |||||
| } | |||||
| return animationName; | |||||
| }; | |||||
| @@ -0,0 +1,16 @@ | |||||
| $headerSize: 60px; | |||||
| $pageMargin: 15px; | |||||
| $headerOffset: 25px; | |||||
| $borderColor: #999; | |||||
| $borderRadius: 30px; | |||||
| $backgroundColor: #fff; | |||||
| $accentColor: #de5200; | |||||
| $sidebar_mobile_breakpoint: 768px; | |||||
| $sidebar_mobile_height: 30vh; | |||||
| $sidebar_width_1920: 15vw; | |||||
| $sidebar_width_1024_1920: 25vw; | |||||
| $sidebar_width_768_1024: 35vw; | |||||
| $sidebar_width_480_768: 50vw; | |||||
| @@ -0,0 +1 @@ | |||||
| REACT_APP_API_URL=http://localhost:9001 | |||||
| @@ -0,0 +1,27 @@ | |||||
| # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. | |||||
| # dependencies | |||||
| /node_modules | |||||
| /.pnp | |||||
| .pnp.js | |||||
| # testing | |||||
| /coverage | |||||
| # production | |||||
| /build | |||||
| # misc | |||||
| .DS_Store | |||||
| .env.local | |||||
| .env.development.local | |||||
| .env.test.local | |||||
| .env.production.local | |||||
| npm-debug.log* | |||||
| yarn-debug.log* | |||||
| yarn-error.log* | |||||
| # Temporary env files | |||||
| /public/env-config.js | |||||
| env-config.js | |||||
| @@ -0,0 +1,26 @@ | |||||
| FROM nginx:1.15.2-alpine | |||||
| # Nginx config | |||||
| RUN rm -rf /etc/nginx/conf.d | |||||
| COPY ./conf /etc/nginx | |||||
| # Static build | |||||
| COPY ./build /usr/share/nginx/html/ | |||||
| # Default port exposure | |||||
| EXPOSE 80 | |||||
| # Copy .env file and shell script to container | |||||
| WORKDIR /usr/share/nginx/html | |||||
| COPY scripts/env.sh . | |||||
| COPY .env . | |||||
| # Add bash | |||||
| RUN apk add --no-cache bash | |||||
| # Make our shell script executable | |||||
| RUN chmod +x env.sh | |||||
| # Start Nginx server | |||||
| CMD ["/bin/bash", "-c", "/usr/share/nginx/html/env.sh && nginx -g \"daemon off;\""] | |||||
| @@ -0,0 +1,13 @@ | |||||
| server { | |||||
| listen 80; | |||||
| location / { | |||||
| root /usr/share/nginx/html; | |||||
| index index.html index.htm; | |||||
| try_files $uri $uri/ /index.html; | |||||
| expires -1; # Set it to different value depending on your standard requirements | |||||
| } | |||||
| error_page 500 502 503 504 /50x.html; | |||||
| location = /50x.html { | |||||
| root /usr/share/nginx/html; | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,24 @@ | |||||
| gzip on; | |||||
| gzip_http_version 1.0; | |||||
| gzip_comp_level 5; # 1-9 | |||||
| gzip_min_length 256; | |||||
| gzip_proxied any; | |||||
| gzip_vary on; | |||||
| # MIME-types | |||||
| gzip_types | |||||
| application/atom+xml | |||||
| application/javascript | |||||
| application/json | |||||
| application/rss+xml | |||||
| application/vnd.ms-fontobject | |||||
| application/x-font-ttf | |||||
| application/x-web-app-manifest+json | |||||
| application/xhtml+xml | |||||
| application/xml | |||||
| font/opentype | |||||
| image/svg+xml | |||||
| image/x-icon | |||||
| text/css | |||||
| text/plain | |||||
| text/x-component; | |||||
| @@ -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" | |||||
| } | |||||
| } | |||||
| @@ -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> | |||||
| @@ -0,0 +1,25 @@ | |||||
| { | |||||
| "short_name": "React App", | |||||
| "name": "Create React App Sample", | |||||
| "icons": [ | |||||
| { | |||||
| "src": "favicon.ico", | |||||
| "sizes": "64x64 32x32 24x24 16x16", | |||||
| "type": "image/x-icon" | |||||
| }, | |||||
| { | |||||
| "src": "logo192.png", | |||||
| "type": "image/png", | |||||
| "sizes": "192x192" | |||||
| }, | |||||
| { | |||||
| "src": "logo512.png", | |||||
| "type": "image/png", | |||||
| "sizes": "512x512" | |||||
| } | |||||
| ], | |||||
| "start_url": ".", | |||||
| "display": "standalone", | |||||
| "theme_color": "#000000", | |||||
| "background_color": "#ffffff" | |||||
| } | |||||
| @@ -0,0 +1,3 @@ | |||||
| # https://www.robotstxt.org/robotstxt.html | |||||
| User-agent: * | |||||
| Disallow: | |||||
| @@ -0,0 +1 @@ | |||||
| docker build . -t photos-client | |||||
| @@ -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 | |||||
| @@ -0,0 +1,32 @@ | |||||
| #!/bin/bash | |||||
| envConfigFile=${1:-./env-config.js} | |||||
| # Recreate config file | |||||
| rm -rf $envConfigFile | |||||
| touch $envConfigFile | |||||
| # Add assignment | |||||
| echo "window._env_ = {" >> $envConfigFile | |||||
| # Read each line in .env file | |||||
| # Each line represents key=value pairs | |||||
| while read -r line || [[ -n "$line" ]]; | |||||
| do | |||||
| # Split env variables by character `=` | |||||
| if printf '%s\n' "$line" | grep -q -e '='; then | |||||
| varname=$(printf '%s\n' "$line" | sed -e 's/=.*//') | |||||
| varvalue=$(printf '%s\n' "$line" | sed -e 's/^[^=]*=//') | |||||
| fi | |||||
| # Read value of current variable if exists as Environment variable | |||||
| value=$(printf '%s\n' "${!varname}") | |||||
| # Otherwise use value from .env file | |||||
| [[ -z $value ]] && value=${varvalue} | |||||
| # Append configuration property to JS file | |||||
| echo " $varname: \"$value\"," >> $envConfigFile | |||||
| done < .env | |||||
| echo "}" >> $envConfigFile | |||||
| @@ -0,0 +1,53 @@ | |||||
| import {createAnimation} from "../utils/animation"; | |||||
| const sync = createAnimation( | |||||
| "SyncLoader", | |||||
| `33% {transform: translateY(10px)} | |||||
| 66% {transform: translateY(-10px)} | |||||
| 100% {transform: translateY(0)}`, | |||||
| "sync" | |||||
| ); | |||||
| function Loader({ | |||||
| loading = false, | |||||
| color = "#000000", | |||||
| speedMultiplier = 1, | |||||
| cssOverride = {}, | |||||
| size = 15, | |||||
| margin = 2, | |||||
| ...additionalprops | |||||
| }) { | |||||
| const wrapper = { | |||||
| display: "inherit", | |||||
| ...cssOverride, | |||||
| }; | |||||
| const style = (i) => { | |||||
| return { | |||||
| backgroundColor: color, | |||||
| width: size, | |||||
| height: size, | |||||
| margin: margin, | |||||
| borderRadius: "100%", | |||||
| display: "inline-block", | |||||
| animation: `${sync} ${0.6 / speedMultiplier}s ${i * 0.07}s infinite ease-in-out`, | |||||
| animationFillMode: "both", | |||||
| }; | |||||
| }; | |||||
| if (!loading) { | |||||
| return null; | |||||
| } | |||||
| return ( | |||||
| <div id="page-loader"> | |||||
| <span style={wrapper} {...additionalprops}> | |||||
| <span style={style(1)}/> | |||||
| <span style={style(2)}/> | |||||
| <span style={style(3)}/> | |||||
| </span> | |||||
| </div> | |||||
| ); | |||||
| } | |||||
| export default Loader; | |||||
| @@ -0,0 +1,128 @@ | |||||
| import * as React from "react"; | |||||
| import {createRef, useEffect, useState} from "react"; | |||||
| import "./photo-viewer.scss" | |||||
| import {ArrowLeft, ArrowRight} from "@mui/icons-material"; | |||||
| import {useCurrentInstance} from "../../services/current-instance/use-current-instance"; | |||||
| export function PhotoViewer() { | |||||
| const {selectedAlbum, selectedPhotoIndex, setSelectedPhotoIndex} = useCurrentInstance(); | |||||
| const [touchStartX, setTouchStartX] = useState(); | |||||
| const [offset, setOffset] = useState(0); | |||||
| const elPhotoViewer = createRef(); | |||||
| const hasNext = selectedPhotoIndex < (selectedAlbum?.photos.length || 0) - 1; | |||||
| const hasPrevious = selectedPhotoIndex > 0; | |||||
| useEffect(() => { | |||||
| if (elPhotoViewer.current) { | |||||
| elPhotoViewer.current.focus(); | |||||
| } | |||||
| }, [elPhotoViewer]); | |||||
| const handleClick = (e) => { | |||||
| if (e.target === elPhotoViewer.current) { | |||||
| setSelectedPhotoIndex(-1); | |||||
| } | |||||
| } | |||||
| const handleGoPrevious = () => { | |||||
| if (hasPrevious) { | |||||
| setSelectedPhotoIndex(selectedPhotoIndex - 1); | |||||
| } | |||||
| } | |||||
| const handleGoNext = () => { | |||||
| if (hasNext) { | |||||
| setSelectedPhotoIndex(selectedPhotoIndex + 1); | |||||
| } | |||||
| } | |||||
| const handleKeyUp = (e) => { | |||||
| switch (e.code) { | |||||
| case "ArrowLeft": | |||||
| handleGoPrevious(); | |||||
| break; | |||||
| case "ArrowRight": | |||||
| handleGoNext(); | |||||
| break; | |||||
| case "Escape": | |||||
| setSelectedPhotoIndex(-1); | |||||
| break; | |||||
| default: | |||||
| break; | |||||
| } | |||||
| } | |||||
| const handleTouchStart = (/** TouchEvent **/e) => { | |||||
| if (e.touches.length === 1) { | |||||
| //just one finger touched | |||||
| setTouchStartX(e.touches.item(0).clientX); | |||||
| } else { | |||||
| //a second finger hit the screen, abort the touch | |||||
| setTouchStartX(null); | |||||
| } | |||||
| } | |||||
| const handleTouchMove = (/** TouchEvent **/e) => { | |||||
| if (e.touches.length === 1) { | |||||
| const end = e.changedTouches.item(0).clientX; | |||||
| setOffset(end - touchStartX); | |||||
| } else { | |||||
| setTouchStartX(null); | |||||
| setOffset(0); | |||||
| } | |||||
| } | |||||
| const handleTouchEnd = (/** TouchEvent **/e) => { | |||||
| const minOffset = 100; //at least 100px are a swipe | |||||
| if (touchStartX) { | |||||
| //the only finger that hit the screen left it | |||||
| const end = e.changedTouches.item(0).clientX; | |||||
| if (end > touchStartX + minOffset) { | |||||
| //a left -> right swipe | |||||
| handleGoPrevious(); | |||||
| } else if (end < touchStartX - minOffset) { | |||||
| //a right -> left swipe | |||||
| handleGoNext(); | |||||
| } | |||||
| } | |||||
| setOffset(0); | |||||
| } | |||||
| const previousPhotoStyle = { | |||||
| right: `calc(100% + ${15 - offset}px)` | |||||
| } | |||||
| const nextPhotoStyle = { | |||||
| left: `calc(100% - ${-offset}px)` | |||||
| } | |||||
| const photoStyle = { | |||||
| marginLeft: `${offset}px` | |||||
| } | |||||
| return <> | |||||
| {selectedPhotoIndex >= 0 ? | |||||
| <div tabIndex={1} ref={elPhotoViewer} id="photoViewer" onClick={handleClick} onKeyUp={handleKeyUp} | |||||
| onTouchStart={handleTouchStart} onTouchEnd={handleTouchEnd} onTouchMove={handleTouchMove}> | |||||
| <div className="body-overlay"></div> | |||||
| <div className="goto-btn goto-btn--previous" onClick={handleGoPrevious}> | |||||
| {hasPrevious && <ArrowLeft/>} | |||||
| </div> | |||||
| <div id="photoSlider"> | |||||
| {hasPrevious && <div className="photo photo--placeholder photo--previous" style={previousPhotoStyle}> | |||||
| <img src={`${selectedAlbum.photos[selectedPhotoIndex - 1].url}`} | |||||
| alt={selectedAlbum.photos[selectedPhotoIndex - 1].name}/> | |||||
| </div>} | |||||
| <div className="photo photo--invisible"> | |||||
| <img src={`${selectedAlbum.photos[selectedPhotoIndex].url}`} | |||||
| alt={selectedAlbum.photos[selectedPhotoIndex].name}/> | |||||
| </div> | |||||
| <div className="photo photo--placeholder" style={photoStyle}> | |||||
| <img src={`${selectedAlbum.photos[selectedPhotoIndex].url}`} | |||||
| alt={selectedAlbum.photos[selectedPhotoIndex].name}/> | |||||
| </div> | |||||
| {hasNext && <div className="photo photo--placeholder photo--next" style={nextPhotoStyle}> | |||||
| <img src={`${selectedAlbum.photos[selectedPhotoIndex + 1].url}`} | |||||
| alt={selectedAlbum.photos[selectedPhotoIndex + 1].name}/> | |||||
| </div>} | |||||
| </div> | |||||
| <div className="goto-btn goto-btn--next" onClick={handleGoNext}> | |||||
| {hasNext && <ArrowRight/>} | |||||
| </div> | |||||
| </div> : null} | |||||
| </> | |||||
| } | |||||
| @@ -0,0 +1,74 @@ | |||||
| #photoViewer { | |||||
| z-index: 999; | |||||
| position: fixed; | |||||
| left: 0; | |||||
| top: 0; | |||||
| width: 100vw; | |||||
| height: 100vh; | |||||
| background: rgba(0, 0, 0, 0.95); | |||||
| display: flex; | |||||
| align-items: center; | |||||
| justify-content: stretch; | |||||
| #photoSlider { | |||||
| position: relative; | |||||
| display: flex; | |||||
| align-items: center; | |||||
| overflow: hidden; | |||||
| flex-shrink: 1; | |||||
| max-height: 90vh; | |||||
| .photo { | |||||
| position: relative; | |||||
| img { | |||||
| margin: 0 auto; | |||||
| max-height: 90vh; | |||||
| max-width: calc(100vw - 300px); | |||||
| @media (max-width: 768px) { | |||||
| max-width: calc(100vw - 150px); | |||||
| } | |||||
| } | |||||
| &.photo--placeholder { | |||||
| position: absolute; | |||||
| } | |||||
| &.photo--invisible { | |||||
| visibility: hidden; | |||||
| } | |||||
| } | |||||
| } | |||||
| .goto-btn { | |||||
| cursor: pointer; | |||||
| flex: 1; | |||||
| &:hover { | |||||
| svg { | |||||
| transform: scale(1.2); | |||||
| opacity: 0.5; | |||||
| } | |||||
| } | |||||
| svg { | |||||
| color: white; | |||||
| opacity: 0.15; | |||||
| width: 75px; | |||||
| height: 75px; | |||||
| @media (max-width: 768px) { | |||||
| width: 75px; | |||||
| height: 75px; | |||||
| } | |||||
| } | |||||
| &.goto-btn--previous { | |||||
| text-align: right; | |||||
| } | |||||
| &.goto-btn--next { | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,20 @@ | |||||
| import {useEffect} from "react"; | |||||
| /** | |||||
| * Hook that alerts clicks outside of the passed ref | |||||
| */ | |||||
| export function useOutsideAlerter(ref, handler) { | |||||
| useEffect(() => { | |||||
| function handleClickOutside(event) { | |||||
| if (ref.current && !ref.current.contains(event.target)) { | |||||
| handler(event); | |||||
| } | |||||
| } | |||||
| // Bind the event listener | |||||
| document.addEventListener("mousedown", handleClickOutside); | |||||
| return () => { | |||||
| // Unbind the event listener on clean up | |||||
| document.removeEventListener("mousedown", handleClickOutside); | |||||
| }; | |||||
| }, [ref, handler]); | |||||
| } | |||||
| @@ -0,0 +1,32 @@ | |||||
| body { | |||||
| margin: 0; | |||||
| font-size: 16pt; | |||||
| font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', | |||||
| 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', | |||||
| sans-serif; | |||||
| -webkit-font-smoothing: antialiased; | |||||
| -moz-osx-font-smoothing: grayscale; | |||||
| } | |||||
| code { | |||||
| font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', | |||||
| monospace; | |||||
| } | |||||
| #root { | |||||
| background: #fff; | |||||
| min-height: 100vh; | |||||
| font-size: 1rem; | |||||
| display: flex; | |||||
| flex-direction: column; | |||||
| } | |||||
| *, | |||||
| *::before, | |||||
| *::after { | |||||
| box-sizing: border-box; | |||||
| } | |||||
| input { | |||||
| font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; | |||||
| } | |||||
| @@ -0,0 +1,45 @@ | |||||
| import React, {useLayoutEffect} from 'react'; | |||||
| import ReactDOM from 'react-dom/client'; | |||||
| import './index.css'; | |||||
| import {BrowserRouter, Navigate, Route, Routes, useLocation} from "react-router-dom"; | |||||
| import '@fontsource/roboto/300.css'; | |||||
| import '@fontsource/roboto/400.css'; | |||||
| import '@fontsource/roboto/500.css'; | |||||
| import '@fontsource/roboto/700.css'; | |||||
| import PublicLayout from "./public/layout"; | |||||
| import Portfolio from "./public/pages/portfolio"; | |||||
| import AlbumPage from "./public/pages/album"; | |||||
| import AccessDenied from "./public/pages/access-denied"; | |||||
| import {HomePage} from "./public/pages/home-page"; | |||||
| import {CurrentInstanceProvider} from "./services/current-instance/current-instance-context"; | |||||
| const root = ReactDOM.createRoot(document.getElementById('root')); | |||||
| root.render( | |||||
| <React.StrictMode> | |||||
| <BrowserRouter> | |||||
| <CurrentInstanceProvider> | |||||
| <ScrollToTop> | |||||
| <Routes> | |||||
| <Route index element={<HomePage/>}/> | |||||
| <Route path="/portfolio" element={<PublicLayout/>}> | |||||
| <Route index element={<Portfolio/>}/> | |||||
| <Route path=":albumId" element={<AlbumPage/>}/> | |||||
| <Route path="401" element={<AccessDenied/>}/> | |||||
| <Route path="*" element={<Navigate to="/"/>}/> | |||||
| </Route> | |||||
| </Routes> | |||||
| </ScrollToTop> | |||||
| </CurrentInstanceProvider> | |||||
| </BrowserRouter> | |||||
| </React.StrictMode> | |||||
| ); | |||||
| function ScrollToTop({children}) { | |||||
| const location = useLocation(); | |||||
| useLayoutEffect(() => { | |||||
| document.documentElement.scrollTo(0, 0); | |||||
| }, [location.pathname]); | |||||
| return children | |||||
| } | |||||
| @@ -0,0 +1,16 @@ | |||||
| export function Logo() { | |||||
| return <svg width="50px" height="40px" fill="currentcolor" viewBox="0 0 49 40"> | |||||
| <g id="surface1"> | |||||
| <path d="M 15.554688 0.195312 C 15.027344 0.40625 14.472656 0.863281 14.191406 1.3125 C 14.019531 1.585938 13.792969 2.351562 13.792969 2.640625 C 13.792969 2.695312 17.792969 2.730469 24.507812 2.730469 L 35.222656 2.730469 L 35.222656 2.542969 C 35.222656 2.210938 35.023438 1.539062 34.824219 1.222656 C 34.554688 0.785156 34.078125 0.40625 33.515625 0.183594 C 33.046875 0 32.96875 0 24.527344 0.0078125 C 16.144531 0.0078125 15.996094 0.0195312 15.554688 0.195312 Z M 15.554688 0.195312 "/> | |||||
| <path d="M 7.289062 3.707031 C 4.960938 3.75 4.683594 3.769531 4.25 3.933594 C 3.390625 4.269531 3.113281 4.402344 2.664062 4.726562 C 1.441406 5.582031 0.695312 6.65625 0.261719 8.160156 C 0.113281 8.679688 0.09375 9.445312 0.0859375 21.761719 L 0.0703125 34.816406 L 0.261719 35.429688 C 0.605469 36.539062 1.015625 37.210938 1.867188 38.070312 C 2.6875 38.917969 3.226562 39.261719 4.363281 39.683594 L 4.988281 39.910156 L 9.542969 39.945312 C 12.050781 39.957031 14.367188 39.9375 14.6875 39.902344 L 15.277344 39.832031 L 14.601562 39.339844 C 13.371094 38.433594 12.023438 37.085938 11.253906 35.976562 L 10.898438 35.476562 L 6.019531 35.476562 L 5.578125 35.253906 C 5.101562 35.007812 4.773438 34.640625 4.617188 34.164062 C 4.46875 33.730469 4.46875 9.894531 4.617188 9.410156 C 4.738281 8.980469 5.195312 8.433594 5.585938 8.238281 C 5.855469 8.105469 7.46875 8.097656 24.5 8.097656 C 39.996094 8.097656 43.171875 8.117188 43.402344 8.222656 C 43.828125 8.398438 44.210938 8.792969 44.417969 9.269531 L 44.601562 9.691406 L 44.539062 21.902344 C 44.488281 33.496094 44.472656 34.128906 44.324219 34.4375 C 44.125 34.839844 43.605469 35.28125 43.1875 35.394531 C 42.96875 35.449219 39.371094 35.476562 32.820312 35.457031 C 23.503906 35.429688 22.738281 35.421875 22.339844 35.273438 C 22.105469 35.195312 21.65625 35.035156 21.34375 34.929688 C 20.066406 34.507812 18.773438 33.660156 17.664062 32.535156 C 16.996094 31.855469 16.136719 30.757812 16.136719 30.582031 C 16.136719 30.554688 16.066406 30.421875 15.972656 30.300781 C 15.746094 29.964844 15.320312 28.847656 15.085938 27.984375 C 14.921875 27.339844 14.894531 27.042969 14.894531 25.75 C 14.894531 24.628906 14.9375 24.136719 15.042969 23.8125 C 15.121094 23.566406 15.183594 23.300781 15.183594 23.214844 C 15.183594 22.957031 15.789062 21.46875 16.164062 20.820312 C 17.757812 18.011719 20.371094 16.285156 23.570312 15.933594 C 26.972656 15.554688 30.652344 17.296875 32.542969 20.195312 C 33.097656 21.046875 33.660156 22.296875 33.886719 23.167969 C 34.070312 23.882812 34.09375 24.171875 34.09375 25.597656 C 34.09375 27.492188 33.949219 28.34375 33.390625 29.570312 C 33.019531 30.371094 32.28125 31.550781 31.734375 32.175781 L 31.386719 32.570312 L 34.136719 32.554688 L 36.878906 32.527344 L 37.046875 32.21875 C 37.816406 30.765625 38.363281 28.792969 38.527344 26.867188 C 38.804688 23.75 38.058594 20.652344 36.402344 18 C 35.007812 15.757812 33.160156 14.074219 30.753906 12.871094 C 27.753906 11.355469 23.632812 11.03125 20.386719 12.050781 C 17.332031 13 14.601562 15.054688 12.855469 17.695312 C 11.851562 19.226562 11.148438 20.949219 10.707031 22.976562 C 10.472656 24.058594 10.472656 27.351562 10.707031 28.433594 C 11.582031 32.457031 13.492188 35.386719 16.640625 37.527344 C 17.984375 38.441406 19.617188 39.144531 21.507812 39.628906 C 22.253906 39.824219 22.296875 39.824219 33.203125 39.851562 L 44.148438 39.886719 L 44.773438 39.65625 C 46.570312 39.023438 47.847656 37.800781 48.53125 36.054688 C 49.042969 34.753906 49.027344 35.441406 48.964844 21.523438 C 48.929688 13.566406 48.878906 8.625 48.828125 8.40625 C 48.226562 5.933594 46.285156 4.09375 43.855469 3.6875 C 43.414062 3.617188 38.816406 3.601562 26.546875 3.617188 C 17.359375 3.636719 8.691406 3.671875 7.289062 3.707031 Z M 7.289062 3.707031 "/> | |||||
| <path d="M 6.707031 10.007812 C 6.628906 10.035156 6.59375 10.265625 6.59375 10.703125 L 6.59375 11.355469 L 13.621094 11.355469 L 13.621094 10.703125 C 13.621094 10.210938 13.585938 10.042969 13.492188 10.007812 C 13.335938 9.9375 6.855469 9.9375 6.707031 10.007812 Z M 6.707031 10.007812 "/> | |||||
| <path d="M 22.902344 17.121094 C 20.65625 17.613281 18.703125 18.890625 17.523438 20.660156 C 17.132812 21.242188 17.109375 21.320312 17.28125 21.257812 C 17.394531 21.222656 18.425781 21.179688 19.582031 21.152344 C 21.246094 21.117188 21.90625 21.136719 22.789062 21.257812 C 24.039062 21.425781 25.339844 21.714844 26.242188 22.042969 C 26.574219 22.164062 26.859375 22.246094 26.878906 22.226562 C 26.988281 22.113281 25.792969 19.929688 24.570312 18.027344 C 23.824219 16.867188 23.902344 16.910156 22.902344 17.121094 Z M 22.902344 17.121094 "/> | |||||
| <path d="M 24.863281 17.21875 C 26.226562 19.277344 26.929688 20.535156 27.5625 22.015625 C 28.089844 23.25 28.324219 24.03125 28.691406 25.738281 L 28.757812 26.082031 L 29.296875 25.253906 C 30.097656 24.003906 31.667969 20.96875 31.667969 20.660156 C 31.667969 20.449219 30.234375 18.96875 29.609375 18.546875 C 28.292969 17.648438 26.816406 17.09375 25.472656 17.015625 L 24.691406 16.964844 Z M 24.863281 17.21875 "/> | |||||
| <path d="M 31.96875 21.671875 C 31.855469 22.035156 30.796875 24.136719 30.261719 25.0625 C 29.480469 26.390625 28.714844 27.386719 27.546875 28.582031 C 26.652344 29.507812 26.519531 29.675781 26.667969 29.71875 C 26.945312 29.804688 29.636719 29.78125 30.625 29.683594 C 31.128906 29.628906 31.753906 29.578125 32.011719 29.558594 L 32.492188 29.542969 L 32.644531 29.144531 C 33.082031 28.027344 33.175781 27.472656 33.175781 25.925781 C 33.183594 24.613281 33.160156 24.339844 32.976562 23.679688 C 32.777344 22.929688 32.394531 22 32.160156 21.644531 L 32.03125 21.460938 Z M 31.96875 21.671875 "/> | |||||
| <path d="M 18.261719 22.007812 C 17.742188 22.042969 17.144531 22.078125 16.945312 22.085938 L 16.585938 22.09375 L 16.363281 22.683594 C 15.484375 25.042969 15.609375 27.570312 16.710938 29.78125 C 16.855469 30.070312 16.960938 30.183594 16.988281 30.105469 C 17.046875 29.902344 17.976562 28.027344 18.304688 27.421875 C 19.214844 25.765625 20.335938 24.277344 21.636719 23 L 22.617188 22.035156 L 22.042969 21.972656 C 21.359375 21.910156 19.570312 21.929688 18.261719 22.007812 Z M 18.261719 22.007812 "/> | |||||
| <path d="M 20.292969 25.667969 C 20.128906 25.855469 19.171875 27.351562 19.171875 27.410156 C 19.171875 27.4375 19.050781 27.667969 18.902344 27.921875 C 18.757812 28.179688 18.359375 28.933594 18.027344 29.605469 C 17.308594 31.046875 17.289062 30.90625 18.347656 32.007812 C 19.796875 33.511719 21.6875 34.445312 23.546875 34.570312 L 24.40625 34.621094 L 23.972656 33.96875 C 22.566406 31.847656 21.523438 29.78125 20.996094 28.035156 C 20.832031 27.492188 20.5 26.082031 20.457031 25.714844 C 20.429688 25.511719 20.421875 25.511719 20.292969 25.667969 Z M 20.292969 25.667969 "/> | |||||
| <path d="M 22.46875 29.675781 C 22.574219 29.886719 22.722656 30.203125 22.800781 30.371094 C 23.46875 31.75 25.273438 34.59375 25.488281 34.59375 C 25.878906 34.59375 26.800781 34.394531 27.492188 34.15625 C 29.332031 33.53125 30.9375 32.261719 31.933594 30.632812 L 32.074219 30.414062 L 29.332031 30.414062 C 26.21875 30.414062 25.359375 30.308594 23.511719 29.699219 C 23.292969 29.621094 23.007812 29.535156 22.859375 29.5 C 22.722656 29.453125 22.53125 29.394531 22.445312 29.355469 C 22.296875 29.296875 22.296875 29.3125 22.46875 29.675781 Z M 22.46875 29.675781 "/> | |||||
| </g> | |||||
| </svg>; | |||||
| } | |||||
| @@ -0,0 +1,128 @@ | |||||
| @import "./variables"; | |||||
| @keyframes sb_slide_negative_left_to_right { | |||||
| from { | |||||
| transform: translateX(-$sidebar_width_1920); | |||||
| @media(max-width: 1920px) { | |||||
| transform: translateX(-$sidebar_width_1024_1920); | |||||
| } | |||||
| @media(max-width: 1024px) { | |||||
| transform: translateX(-$sidebar_width_768_1024); | |||||
| } | |||||
| @media(max-width: 768px) { | |||||
| transform: translateX(-$sidebar_width_480_768); | |||||
| } | |||||
| } | |||||
| to { | |||||
| transform: translateX(0); | |||||
| } | |||||
| } | |||||
| @mixin sb_slide_negative_left_to_right() { | |||||
| transition: 0.6s; | |||||
| animation: ease-out; | |||||
| animation-name: sb_slide_negative_left_to_right; | |||||
| transform: translateX(0); | |||||
| } | |||||
| @keyframes sb_slide_right_to_negative_left { | |||||
| from { | |||||
| transform: translateX(0); | |||||
| } | |||||
| to { | |||||
| transform: translateX(-$sidebar_width_1920); | |||||
| @media(max-width: 1920px) { | |||||
| transform: translateX(-$sidebar_width_1024_1920); | |||||
| } | |||||
| @media(max-width: 1024px) { | |||||
| transform: translateX(-$sidebar_width_768_1024); | |||||
| } | |||||
| @media(max-width: 768px) { | |||||
| transform: translateX(-$sidebar_width_480_768); | |||||
| } | |||||
| } | |||||
| } | |||||
| @mixin sb_slide_right_to_negative_left() { | |||||
| transition: 0.6s; | |||||
| animation: ease-out; | |||||
| animation-name: sb_slide_right_to_negative_left; | |||||
| transform: translateX(-1 * $sidebar_width_1920); | |||||
| @media(max-width: 1920px) { | |||||
| transform: translateX(-1 * $sidebar_width_1024_1920); | |||||
| } | |||||
| @media(max-width: 1024px) { | |||||
| transform: translateX(-1 * $sidebar_width_768_1024); | |||||
| } | |||||
| @media(max-width: 768px) { | |||||
| transform: translateX(-1 * $sidebar_width_480_768); | |||||
| } | |||||
| } | |||||
| @keyframes sb_slide_left_to_right { | |||||
| from { | |||||
| transform: translateX(0); | |||||
| } | |||||
| to { | |||||
| transform: translateX($sidebar_width_1920); | |||||
| @media(max-width: 1920px) { | |||||
| transform: translateX($sidebar_width_1024_1920); | |||||
| } | |||||
| @media(max-width: 1024px) { | |||||
| transform: translateX($sidebar_width_768_1024); | |||||
| } | |||||
| @media(max-width: 768px) { | |||||
| transform: translateX($sidebar_width_480_768); | |||||
| } | |||||
| } | |||||
| } | |||||
| @mixin sb_slide_left_to_right() { | |||||
| transition: 0.6s; | |||||
| animation: ease-out; | |||||
| animation-name: sb_slide_left_to_right; | |||||
| transform: translateX($sidebar_width_1920); | |||||
| @media(max-width: 1920px) { | |||||
| transform: translateX($sidebar_width_1024_1920); | |||||
| } | |||||
| @media(max-width: 1024px) { | |||||
| transform: translateX($sidebar_width_768_1024); | |||||
| } | |||||
| @media(max-width: 768px) { | |||||
| transform: translateX($sidebar_width_480_768); | |||||
| } | |||||
| } | |||||
| @keyframes sb_slide_right_to_left { | |||||
| from { | |||||
| transform: translateX($sidebar_width_1920); | |||||
| @media(max-width: 1920px) { | |||||
| transform: translateX($sidebar_width_1024_1920); | |||||
| } | |||||
| @media(max-width: 1024px) { | |||||
| transform: translateX($sidebar_width_768_1024); | |||||
| } | |||||
| @media(max-width: 768px) { | |||||
| transform: translateX($sidebar_width_480_768); | |||||
| } | |||||
| } | |||||
| to { | |||||
| transform: translateX(0); | |||||
| } | |||||
| } | |||||
| @mixin sb_slide_right_to_left() { | |||||
| transition: 0.6s; | |||||
| animation: ease-out; | |||||
| animation-name: sb_slide_right_to_left; | |||||
| transform: translateX(0); | |||||
| } | |||||
| @@ -0,0 +1,34 @@ | |||||
| import {Photo} from "./photo.js"; | |||||
| export class Album { | |||||
| /** | |||||
| * @param {Album} [album] | |||||
| */ | |||||
| constructor(album) { | |||||
| this._id = album?._id; | |||||
| this.albumId = album?.albumId; | |||||
| this.name = album?.name; | |||||
| this.description = album?.description; | |||||
| this.sort = album?.sort; | |||||
| this.sortOrder = album?.sortOrder; | |||||
| this.photos = album?.photos?.map(photo => new Photo(photo)) || []; | |||||
| this.coverPhoto = this.photos.length > 0 ? this.photos[0] : null; | |||||
| } | |||||
| /** @type {string} **/ | |||||
| id; | |||||
| /** @type {string} **/ | |||||
| albumId; | |||||
| /** @type {string} **/ | |||||
| name; | |||||
| /** @type {string} **/ | |||||
| description; | |||||
| /** @type {"manual"|"auto"} **/ | |||||
| sort; | |||||
| /** @type {number} **/ | |||||
| sortOrder; | |||||
| /** @type {Photo[]} **/ | |||||
| photos; | |||||
| /** @type {Photo} **/ | |||||
| coverPhoto; | |||||
| } | |||||
| @@ -0,0 +1,11 @@ | |||||
| import {Photo} from "./photo"; | |||||
| export class Instance { | |||||
| constructor(instance) { | |||||
| this._id = instance?._id; | |||||
| this.instanceId = instance?.instanceId; | |||||
| this.title = instance?.title; | |||||
| this.subtitle = instance?.subtitle; | |||||
| this.coverPhoto = instance?.coverPhoto ? new Photo(instance.coverPhoto) : null; | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,29 @@ | |||||
| export class Photo { | |||||
| /** | |||||
| * @param {Photo} photo | |||||
| */ | |||||
| constructor(photo) { | |||||
| this.photoId = photo?.photoId; | |||||
| this.albumId = photo?.albumId; | |||||
| this.name = photo?.name; | |||||
| this.fileName = photo?.fileName; | |||||
| this.mimeType = photo?.mimeType; | |||||
| this.s3Path = photo?.s3Path; | |||||
| this.description = photo?.description; | |||||
| this.sortOrder = photo?.sortOrder; | |||||
| this.url = `${window._env_.REACT_APP_API_URL}/api/s3/${this.s3Path}`; | |||||
| } | |||||
| /** @type {string} **/ | |||||
| photoId; | |||||
| /** @type {string} **/ | |||||
| albumId; | |||||
| /** @type {string} **/ | |||||
| name; | |||||
| /** @type {string} **/ | |||||
| s3Path; | |||||
| /** @type {string} **/ | |||||
| description; | |||||
| /** @type {number} **/ | |||||
| sortOrder; | |||||
| } | |||||
| @@ -0,0 +1,5 @@ | |||||
| import {createContext} from "react"; | |||||
| export const PageTransitionContext = createContext({ | |||||
| mounted: false | |||||
| }) | |||||
| @@ -0,0 +1,57 @@ | |||||
| import * as React from "react"; | |||||
| import "./public.scss" | |||||
| import {useLayoutEffect, useState} from "react"; | |||||
| import {NavLink, Outlet, useLocation} from "react-router-dom"; | |||||
| import {KeyboardArrowRightRounded, Menu} from "@mui/icons-material"; | |||||
| import {PageTransitionContext} from "./context/pageTransition"; | |||||
| import {PhotoViewer} from "../components/photo-viewer/photo-viewer"; | |||||
| import Loader from "../components/Loader"; | |||||
| import {useCurrentInstance} from "../services/current-instance/use-current-instance"; | |||||
| export default function PublicLayout() { | |||||
| const {isLoading, apiError, albums} = useCurrentInstance(); | |||||
| const [mounted, setMounted] = useState(false); | |||||
| const pageTransition = { | |||||
| mounted, setMounted | |||||
| } | |||||
| // const fetchedRef = useRef(false); | |||||
| const [sidebarIsOpen, setSidebarIsOpen] = useState(false); | |||||
| const location = useLocation(); | |||||
| useLayoutEffect(() => { | |||||
| setMounted(false); | |||||
| setTimeout(() => { | |||||
| setMounted(true); | |||||
| }, 10); | |||||
| }, [location.pathname]); | |||||
| return <PageTransitionContext.Provider value={pageTransition}> | |||||
| <PhotoViewer /> | |||||
| <div id="publicPage"> | |||||
| <main id="page"> | |||||
| <Loader loading={isLoading} /> | |||||
| {!isLoading && <> | |||||
| <div id="sidebarButton" onClick={() => setSidebarIsOpen(!sidebarIsOpen)}> | |||||
| <Menu /> | |||||
| </div> | |||||
| <div id="sidebar" className={`${(sidebarIsOpen ? "sidebar-open" : "sidebar-closed")}`}> | |||||
| <div className="sidebar"> | |||||
| <NavLink onClick={() => setSidebarIsOpen(false)} to="/" className="home">Home</NavLink> | |||||
| {albums.map(a => <NavLink onClick={() => setSidebarIsOpen(false)} key={a.albumId} to={a.albumId}><KeyboardArrowRightRounded /> {a.name}</NavLink>)} | |||||
| </div> | |||||
| </div> | |||||
| <div id="sidebar-mobile" className={`sidebar ${(sidebarIsOpen ? "sidebar-open" : "sidebar-closed")}`}> | |||||
| <NavLink onClick={() => setSidebarIsOpen(false)} to="/" className="home">Home</NavLink> | |||||
| {albums.map(a => <NavLink onClick={() => setSidebarIsOpen(false)} key={a.albumId} to={a.albumId}><KeyboardArrowRightRounded /> {a.name}</NavLink>)} | |||||
| </div> | |||||
| <div id={"page-content"} className={`${(sidebarIsOpen ? "sidebar-open" : "sidebar-closed")} ${(pageTransition.mounted ? "mounted" : "unmounted")}`}> | |||||
| {apiError && <div className="error-box">{apiError}</div>} | |||||
| {!apiError && <Outlet />} | |||||
| </div> | |||||
| </>} | |||||
| </main> | |||||
| </div> | |||||
| </PageTransitionContext.Provider> | |||||
| } | |||||
| @@ -0,0 +1,7 @@ | |||||
| function AccessDenied() { | |||||
| return ( | |||||
| <h1>Access denied!</h1> | |||||
| ) | |||||
| } | |||||
| export default AccessDenied; | |||||
| @@ -0,0 +1,53 @@ | |||||
| import * as React from "react"; | |||||
| import {useEffect} from "react"; | |||||
| import {useParams} from "react-router-dom"; | |||||
| import {ZoomOutMapSharp} from "@mui/icons-material"; | |||||
| import {useCurrentInstance} from "../../services/current-instance/use-current-instance"; | |||||
| export default function AlbumPage() { | |||||
| const { | |||||
| albums, | |||||
| selectedAlbum, | |||||
| setSelectedAlbum, | |||||
| setSelectedPhotoIndex | |||||
| } = useCurrentInstance(); | |||||
| const {albumId} = useParams(); | |||||
| useEffect(() => { | |||||
| const ab = albums.filter(a => a.albumId === albumId)[0]; | |||||
| if (selectedAlbum !== ab) { | |||||
| setSelectedAlbum(ab); | |||||
| } | |||||
| }, [albums, selectedAlbum, albumId, setSelectedAlbum]); | |||||
| return selectedAlbum && <div id="page-album" className="page-outlet"> | |||||
| <div className="page-title"> | |||||
| <div className="title"> | |||||
| {selectedAlbum.name} | |||||
| </div> | |||||
| {selectedAlbum.description && | |||||
| <div className="extra-info" | |||||
| dangerouslySetInnerHTML={{__html: selectedAlbum.description.replace(/\n/g, "<br />")}}> | |||||
| </div>} | |||||
| </div> | |||||
| {selectedAlbum.photos && <div id="photo-list" className="photo-grid-layout"> | |||||
| {selectedAlbum.photos.map((photo, idx) => | |||||
| <div className="photo grid-cell" key={photo.photoId} | |||||
| onClick={() => setSelectedPhotoIndex(idx)}> | |||||
| <div className="thumbnail photo-thumbnail"> | |||||
| <img src={`${photo.url}?width=800&height=800&fit=cover`} | |||||
| alt={photo.name}/> | |||||
| <div className="zoom-button"> | |||||
| <ZoomOutMapSharp/> | |||||
| </div> | |||||
| </div> | |||||
| <div className="details"> | |||||
| <div className="name"> | |||||
| {photo.name} | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| )} | |||||
| </div>} | |||||
| </div> | |||||
| } | |||||
| @@ -0,0 +1,23 @@ | |||||
| import {useCurrentInstance} from "../../services/current-instance/use-current-instance"; | |||||
| import "./home-page.scss"; | |||||
| import {NavLink} from "react-router-dom"; | |||||
| export function HomePage() { | |||||
| const {instance} = useCurrentInstance(); | |||||
| return instance && <div id="home"> | |||||
| <div className="cover-photo"> | |||||
| <img alt={instance.coverPhoto?.name} src={instance.coverPhoto?.url}/> | |||||
| </div> | |||||
| <div className="instance-details"> | |||||
| <div className="box"> | |||||
| <h1>{instance.title}</h1> | |||||
| <h2>{instance.subtitle}</h2> | |||||
| <NavLink to="/portfolio" className="goto-website-link"> | |||||
| GO TO WEBSITE | |||||
| </NavLink> | |||||
| </div> | |||||
| </div> | |||||
| </div>; | |||||
| } | |||||
| @@ -0,0 +1,99 @@ | |||||
| #home { | |||||
| height: 100vh; | |||||
| width: 100vw; | |||||
| display: grid; | |||||
| grid-template-columns: 1fr 1fr; | |||||
| @media(max-width: 1200px) { | |||||
| grid-template-columns: 1fr; | |||||
| grid-template-rows: 1fr 1fr; | |||||
| } | |||||
| .cover-photo { | |||||
| overflow: hidden; | |||||
| display: flex; | |||||
| justify-content: center; | |||||
| align-items: center; | |||||
| img { | |||||
| width: 100%; | |||||
| height: 100%; | |||||
| object-position: 50% 50%; | |||||
| object-fit: cover; | |||||
| } | |||||
| } | |||||
| .instance-details { | |||||
| display: flex; | |||||
| justify-content: center; | |||||
| flex-direction: column; | |||||
| .box { | |||||
| position: static !important; | |||||
| transform: none; | |||||
| margin-left: auto; | |||||
| margin-right: auto; | |||||
| padding: 60px; | |||||
| max-width: 620px; | |||||
| text-align: center; | |||||
| h1 { | |||||
| text-align: center; | |||||
| display: inline-block; | |||||
| font-size: 37px; | |||||
| line-height: 1em; | |||||
| margin: 0; | |||||
| margin-bottom: 22px; | |||||
| //font-family: proxima-nova; | |||||
| font-weight: 700; | |||||
| font-style: normal; | |||||
| text-transform: uppercase; | |||||
| letter-spacing: .1em; | |||||
| color: #333; | |||||
| @media(max-width: 600px) { | |||||
| font-size: 24px; | |||||
| } | |||||
| } | |||||
| h2 { | |||||
| //font-family: minion-pro; | |||||
| font-size: 19px; | |||||
| font-weight: 400; | |||||
| font-style: normal; | |||||
| text-transform: uppercase; | |||||
| letter-spacing: .28em; | |||||
| line-height: 1.7em; | |||||
| color: #333; | |||||
| margin: 0; | |||||
| margin-bottom: 36px; | |||||
| } | |||||
| .goto-website-link { | |||||
| text-rendering: optimizeLegibility; | |||||
| white-space: nowrap; | |||||
| line-height: 1em; | |||||
| //font-family: proxima-nova; | |||||
| font-weight: 500; | |||||
| font-style: normal; | |||||
| font-size: 14px; | |||||
| text-transform: uppercase; | |||||
| letter-spacing: 0; | |||||
| text-align: center; | |||||
| padding: 1em calc(1.44em - 0em) 1em 1.44em; | |||||
| display: inline-block; | |||||
| text-decoration: none; | |||||
| background-color: transparent; | |||||
| color: #333; | |||||
| border: 2px solid #333; | |||||
| transition: background-color 170ms ease-in-out, color 170ms ease-in-out; | |||||
| //transition: color 170ms ease-in-out, border-color 170ms ease-in-out; | |||||
| &:hover { | |||||
| background-color: black; | |||||
| color: white; | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,33 @@ | |||||
| import {Link} from "react-router-dom"; | |||||
| import * as React from "react"; | |||||
| import {ImageSearch} from "@mui/icons-material"; | |||||
| import {useCurrentInstance} from "../../services/current-instance/use-current-instance"; | |||||
| function Portfolio() { | |||||
| const {instance, albums} = useCurrentInstance(); | |||||
| return instance && <div id="page-portfolio" className="page-outlet"> | |||||
| <div className="page-title"> | |||||
| <div className="title"> | |||||
| {instance.title} | |||||
| </div> | |||||
| </div> | |||||
| {albums?.length > 0 && <div id="album-list" className="photo-grid-layout"> | |||||
| {albums.map((album) => | |||||
| <div className="album grid-cell" key={album.albumId}> | |||||
| <Link to={album.albumId} className={`thumbnail ${(album.coverPhoto ? "" : "thumbnail--no-img")}`}> | |||||
| {album.coverPhoto && <img src={`${album.coverPhoto.url}?width=800&height=800&fit=cover`} alt={album.coverPhoto.name} />} | |||||
| {!album.coverPhoto && <ImageSearch />} | |||||
| </Link> | |||||
| <div className="details"> | |||||
| <div className="name"> | |||||
| {album.name} | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| )} | |||||
| </div>} | |||||
| </div>; | |||||
| } | |||||
| export default Portfolio; | |||||
| @@ -0,0 +1,308 @@ | |||||
| @import "../variables.scss"; | |||||
| @import "../mixins.scss"; | |||||
| html:has(#photoViewer) { | |||||
| overflow: hidden; | |||||
| } | |||||
| #publicPage { | |||||
| margin: 0 auto; | |||||
| width: 100%; | |||||
| flex: 1; | |||||
| display: flex; | |||||
| flex-direction: row; | |||||
| padding: 15px; | |||||
| #page { | |||||
| width: 100%; | |||||
| overflow: hidden; | |||||
| position: relative; | |||||
| flex: 1; | |||||
| @media(max-width: $sidebar_mobile_breakpoint) { | |||||
| flex-direction: column; | |||||
| } | |||||
| #sidebarButton { | |||||
| cursor: pointer; | |||||
| z-index: 999; | |||||
| position: relative; | |||||
| width: 30px; | |||||
| height: 30px; | |||||
| > svg { | |||||
| width: 30px; | |||||
| height: 30px; | |||||
| } | |||||
| } | |||||
| #sidebar { | |||||
| padding-left: 15px; | |||||
| padding-top: 15px; | |||||
| height: 100vh; | |||||
| overflow: hidden; | |||||
| display: flex; | |||||
| flex-direction: column; | |||||
| position: fixed; | |||||
| @media(max-width: $sidebar_mobile_breakpoint) { | |||||
| display: none; | |||||
| } | |||||
| &.sidebar-open { | |||||
| @include sb_slide_negative_left_to_right(); | |||||
| } | |||||
| &.sidebar-closed { | |||||
| @include sb_slide_right_to_negative_left(); | |||||
| } | |||||
| } | |||||
| #sidebar-mobile { | |||||
| padding-left: 50px; | |||||
| background: white; | |||||
| width: 100%; | |||||
| overflow-y: auto; | |||||
| display: none; | |||||
| flex-direction: column; | |||||
| height: $sidebar_mobile_height; | |||||
| border-bottom: 1px solid #000; | |||||
| @media(max-width: $sidebar_mobile_breakpoint) { | |||||
| display: flex; | |||||
| } | |||||
| @keyframes slideDown { | |||||
| from { | |||||
| margin-top: calc(-1 * $sidebar_mobile_height - 50px); | |||||
| } | |||||
| to { | |||||
| margin-top: 0; | |||||
| } | |||||
| } | |||||
| &.sidebar-open { | |||||
| margin-top: 0; | |||||
| transition: 0.5s; | |||||
| animation: ease-in; | |||||
| animation-name: slideDown; | |||||
| } | |||||
| @keyframes slideUp { | |||||
| from { | |||||
| margin-top: 0; | |||||
| } | |||||
| to { | |||||
| margin-top: calc(-1 * $sidebar_mobile_height - 50px); | |||||
| } | |||||
| } | |||||
| &.sidebar-closed { | |||||
| margin-top: calc(-1 * $sidebar_mobile_height - 50px); | |||||
| transition: 0.5s; | |||||
| animation: ease-in; | |||||
| animation-name: slideUp | |||||
| } | |||||
| } | |||||
| .sidebar { | |||||
| > a { | |||||
| width: 100%; | |||||
| font-weight: 400; | |||||
| line-height: 1.4em; | |||||
| color: #000; | |||||
| text-decoration: none; | |||||
| display: flex; | |||||
| align-items: flex-start; | |||||
| margin-bottom: 5px; | |||||
| svg { | |||||
| height: 19px; | |||||
| width: 24px; | |||||
| } | |||||
| &:hover, | |||||
| &.active { | |||||
| font-weight: 600; | |||||
| } | |||||
| &:visited, | |||||
| &:hover { | |||||
| color: #000; | |||||
| } | |||||
| } | |||||
| } | |||||
| #page-content { | |||||
| padding-top: 20px; | |||||
| display: flex; | |||||
| width: 100%; | |||||
| .page-outlet { | |||||
| transform: translateX(100vw); | |||||
| @media (max-width: $sidebar_mobile_breakpoint) { | |||||
| transform: translateX(0); | |||||
| margin-top: 50px; | |||||
| } | |||||
| } | |||||
| &.mounted { | |||||
| .page-outlet { | |||||
| @include sb_slide_right_to_left(); | |||||
| } | |||||
| } | |||||
| &.sidebar-closed { | |||||
| @include sb_slide_right_to_left(); | |||||
| @media (max-width: $sidebar_mobile_breakpoint) { | |||||
| animation: none; | |||||
| transform: translateX(0); | |||||
| } | |||||
| } | |||||
| &.sidebar-open { | |||||
| @include sb_slide_left_to_right(); | |||||
| @media (max-width: $sidebar_mobile_breakpoint) { | |||||
| animation: none; | |||||
| transform: translateX(0); | |||||
| } | |||||
| } | |||||
| .page-title { | |||||
| margin-bottom: 15px; | |||||
| .title { | |||||
| font-size: 2.4rem; | |||||
| margin-bottom: 15px; | |||||
| } | |||||
| .extra-info { | |||||
| font-size: 1.2rem; | |||||
| color: #333; | |||||
| font-style: italic; | |||||
| } | |||||
| } | |||||
| .photo-grid-layout { | |||||
| width: 100%; | |||||
| display: grid; | |||||
| grid-template-columns: repeat(4, 1fr); | |||||
| grid-auto-rows: 1fr; | |||||
| row-gap: calc(4em + 30px); | |||||
| column-gap: 30px; | |||||
| margin-bottom: 4em; | |||||
| //@media (max-width: 1920px) { | |||||
| // grid-template-columns: repeat(4, 1fr); | |||||
| //} | |||||
| //@media (max-width: 1280px) { | |||||
| // grid-template-columns: repeat(4, 1fr); | |||||
| //} | |||||
| @media (max-width: 768px) { | |||||
| grid-template-columns: repeat(2, 1fr); | |||||
| } | |||||
| .grid-cell { | |||||
| display: flex; | |||||
| flex-direction: column; | |||||
| align-items: center; | |||||
| justify-content: space-between; | |||||
| position: relative; | |||||
| cursor: pointer; | |||||
| &:hover { | |||||
| //transform: scale(1.05); | |||||
| } | |||||
| .thumbnail { | |||||
| width: 100%; | |||||
| position: relative; | |||||
| img { | |||||
| width: 100%; | |||||
| } | |||||
| .zoom-button { | |||||
| position: absolute; | |||||
| left: 0; | |||||
| top: 0; | |||||
| height: 100%; | |||||
| width: 100%; | |||||
| display: flex; | |||||
| align-items: center; | |||||
| background-color:rgba(0,0,0, 0); | |||||
| transition: background-color 0.6s; | |||||
| &:hover { | |||||
| background-color:rgba(0,0,0, 0.35); | |||||
| svg { | |||||
| transform: scale(1.4); | |||||
| color: rgba(255, 255, 255, 0.7); | |||||
| } | |||||
| } | |||||
| svg { | |||||
| margin: 0 auto; | |||||
| width: 20%; | |||||
| height: 20%; | |||||
| color: rgba(255, 255, 255, 0.2); | |||||
| transition: transform 0.6s, color 0.6s; | |||||
| } | |||||
| } | |||||
| &.thumbnail--no-img { | |||||
| width: 100%; | |||||
| text-align: center; | |||||
| height: 100%; | |||||
| padding-top: calc(50% - 75px); | |||||
| > svg { | |||||
| width: 75px; | |||||
| height: 75px; | |||||
| color: $accentColor; | |||||
| } | |||||
| } | |||||
| } | |||||
| .details { | |||||
| position: absolute; | |||||
| bottom: calc(-4em + 5px); | |||||
| display: flex; | |||||
| flex-direction: column; | |||||
| height: 4em; | |||||
| width: 80%; | |||||
| margin: 0 auto; | |||||
| text-align: center; | |||||
| .name { | |||||
| margin-top: 15px; | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| .error-box { | |||||
| padding: 0 30px; | |||||
| height: 60px; | |||||
| line-height: 60px; | |||||
| background: lighten(red, 46); | |||||
| border: 2px solid crimson; | |||||
| border-radius: $borderRadius; | |||||
| color: crimson; | |||||
| font-size: 1.2rem; | |||||
| margin: 15px auto 0; | |||||
| } | |||||
| #page-loader { | |||||
| position: absolute; | |||||
| padding-left: 50%; | |||||
| padding-top: 120px; | |||||
| > span > span { | |||||
| background-color: $accentColor !important; | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,34 @@ | |||||
| import {Album} from "../../models/album"; | |||||
| import {Instance} from "../../models/instance"; | |||||
| const apiUrl = window._env_.REACT_APP_API_URL; | |||||
| export default function usePublicApi() { | |||||
| return { | |||||
| fetchAlbums | |||||
| }; | |||||
| /** | |||||
| * @return {Promise<{instance: Instance, albums: Album[]}>} | |||||
| */ | |||||
| async function fetchAlbums() { | |||||
| const response = await handleResponse(await fetch(`${apiUrl}/api/albums`)); | |||||
| return { | |||||
| instance: new Instance(response.data.instance), | |||||
| albums: response.data.albums.map(album => new Album(album)) | |||||
| }; | |||||
| } | |||||
| async function handleResponse(response) { | |||||
| const text = await response.text() | |||||
| const data = text && JSON.parse(text); | |||||
| if (!response.ok) { | |||||
| const error = (data && data.message) || response.statusText; | |||||
| return Promise.reject(error); | |||||
| } | |||||
| return data; | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,57 @@ | |||||
| import React, {useEffect, useState} from "react"; | |||||
| import usePublicApi from "../../public/services/public-api"; | |||||
| export const CurrentInstanceContext = React.createContext( | |||||
| undefined | |||||
| ); | |||||
| export const CurrentInstanceProvider = ({children}) => { | |||||
| const {fetchAlbums} = usePublicApi(); | |||||
| const [initialized, setInitialized] = useState(false); | |||||
| const [instance, setInstance] = useState(null); | |||||
| const [albums, setAlbums] = useState([]); | |||||
| const [isLoading, setIsLoading] = useState(false); | |||||
| const [apiError, setApiError] = useState(null); | |||||
| const [selectedAlbum, setSelectedAlbum] = useState(null); | |||||
| const [selectedPhotoIndex, setSelectedPhotoIndex] = useState(-1); | |||||
| useEffect(() => { | |||||
| void (async () => { | |||||
| if (fetchAlbums && !initialized) { | |||||
| setInitialized(true); | |||||
| try { | |||||
| setApiError(null); | |||||
| const {instance, albums} = await fetchAlbums(); | |||||
| document.title = `${instance.title} | ${instance.subtitle}`; | |||||
| setAlbums(albums); | |||||
| setInstance(instance); | |||||
| setIsLoading(false); | |||||
| } catch (err) { | |||||
| console.error("Could not fetch albums", err); | |||||
| setApiError("An error occurred trying to fetch your albums, please contact the system administrator"); | |||||
| setIsLoading(false); | |||||
| setAlbums([]); | |||||
| setInstance(null); | |||||
| } | |||||
| } | |||||
| })(); | |||||
| }, [fetchAlbums, initialized]); | |||||
| const value = { | |||||
| instance, | |||||
| setInstance, | |||||
| albums, | |||||
| setAlbums, | |||||
| isLoading, | |||||
| setIsLoading, | |||||
| apiError, | |||||
| setApiError, | |||||
| selectedAlbum, | |||||
| setSelectedAlbum, | |||||
| selectedPhotoIndex, | |||||
| setSelectedPhotoIndex | |||||
| }; | |||||
| return <CurrentInstanceContext.Provider value={value}>{children}</CurrentInstanceContext.Provider>; | |||||
| }; | |||||
| @@ -0,0 +1,17 @@ | |||||
| import {CurrentInstanceContext} from "./current-instance-context"; | |||||
| import {useContext} from "react"; | |||||
| /** | |||||
| * @return {InstancesContext} | |||||
| */ | |||||
| export const useCurrentInstance = () => { | |||||
| const context = useContext(CurrentInstanceContext); | |||||
| if (!context) { | |||||
| throw new Error( | |||||
| 'Current instance context is undefined, please verify you are calling useCurrentInstance() as child of a <CurrentInstanceProvider> component.', | |||||
| ); | |||||
| } | |||||
| return context; | |||||
| }; | |||||
| @@ -0,0 +1,23 @@ | |||||
| export const createAnimation = (loaderName, frames, suffix) => { | |||||
| const animationName = `react-spinners-${loaderName}-${suffix}`; | |||||
| if (typeof window == "undefined" || !window.document) { | |||||
| return animationName; | |||||
| } | |||||
| const styleEl = document.createElement("style"); | |||||
| document.head.appendChild(styleEl); | |||||
| const styleSheet = styleEl.sheet; | |||||
| const keyFrames = ` | |||||
| @keyframes ${animationName} { | |||||
| ${frames} | |||||
| } | |||||
| `; | |||||
| if (styleSheet) { | |||||
| styleSheet.insertRule(keyFrames, 0); | |||||
| } | |||||
| return animationName; | |||||
| }; | |||||
| @@ -0,0 +1,16 @@ | |||||
| $headerSize: 60px; | |||||
| $pageMargin: 15px; | |||||
| $headerOffset: 25px; | |||||
| $borderColor: #999; | |||||
| $borderRadius: 30px; | |||||
| $backgroundColor: #fff; | |||||
| $accentColor: #de5200; | |||||
| $sidebar_mobile_breakpoint: 768px; | |||||
| $sidebar_mobile_height: 30vh; | |||||
| $sidebar_width_1920: 15vw; | |||||
| $sidebar_width_1024_1920: 25vw; | |||||
| $sidebar_width_768_1024: 35vw; | |||||
| $sidebar_width_480_768: 50vw; | |||||
| @@ -0,0 +1,19 @@ | |||||
| { | |||||
| "name": "photos", | |||||
| "version": "1.0.0", | |||||
| "description": "Novox photos manager application", | |||||
| "scripts": { | |||||
| "bootstrap": "npm ci && cd client && npm ci && cd ../server && npm ci", | |||||
| "dev": "npm-run-all -p dev-client dev-server", | |||||
| "dev-client": "cd client && npm run dev", | |||||
| "dev-server": "cd server && npm run dev", | |||||
| "build": "npm-run-all -p build-client build-server", | |||||
| "build-client": "cd client && npm run build", | |||||
| "build-server": "cd server && npm run build" | |||||
| }, | |||||
| "author": "", | |||||
| "license": "ISC", | |||||
| "dependencies": { | |||||
| "npm-run-all": "^4.1.5" | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,2 @@ | |||||
| /node_modules | |||||
| /dist | |||||
| @@ -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"] | |||||
| @@ -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" | |||||
| } | |||||
| } | |||||