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