Quellcode durchsuchen

Fixed admin client

master
jochen vor 1 Jahr
Ursprung
Commit
f03dc2c3a6
14 geänderte Dateien mit 225 neuen und 185 gelöschten Zeilen
  1. +23
    -0
      admin-client/src/components/breadcrumb.js
  2. +16
    -0
      admin-client/src/components/breadcrumb.scss
  3. +2
    -6
      admin-client/src/components/header.js
  4. +42
    -36
      admin-client/src/index.js
  5. +47
    -16
      admin-client/src/layout.js
  6. +27
    -18
      admin-client/src/pages/instances-page.js
  7. +28
    -0
      admin-client/src/pages/instances-page.scss
  8. +0
    -82
      admin-client/src/pages/manage-instance-page.js
  9. +2
    -1
      admin-client/src/pages/portfolio-manager.js
  10. +10
    -18
      admin-client/src/services/instances/instances-context.js
  11. +9
    -6
      server/src/api/private/albums.js
  12. +10
    -1
      server/src/api/private/instances.js
  13. +1
    -1
      server/src/services/albums.js
  14. +8
    -0
      server/src/services/instances.js

+ 23
- 0
admin-client/src/components/breadcrumb.js Datei anzeigen

@@ -0,0 +1,23 @@
import {useMatches} from "react-router-dom";
import "./breadcrumb.scss";

export function Breadcrumb() {
let matches = useMatches();
let crumbs = matches
// first get rid of any matches that don't have handle and crumb
.filter((match) => Boolean(match.handle?.crumb))
// now map them into an array of elements, passing the loader
// data to each one
.map((match) => match.handle.crumb(match));

return <>
<div id="breadcrumb">
{crumbs.map((crumb, index) => <span key={index}>
<span className="crumb">{crumb}</span>
{index > 0 && index < crumbs.length - 1
? <span className="separator">/</span>
: <></>}
</span>)}
</div>
</>;
}

+ 16
- 0
admin-client/src/components/breadcrumb.scss Datei anzeigen

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

#breadcrumb {
display: flex;
align-items: center;
justify-content: flex-start;

a {
color: $accentColor;
}

span.crumb {
display: inline-block;
margin: 0 5px;
}
}

+ 2
- 6
admin-client/src/components/header.js Datei anzeigen

@@ -2,16 +2,15 @@ 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 {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";
import {Breadcrumb} from "./breadcrumb";

export default function Header() {
const {selectedInstance} = useInstances();
const navigate = useNavigate();
const {userData, userManager} = useAuth();
const [showUserDropout, setShowUserDropout] = useState(false);
@@ -32,9 +31,6 @@ export default function Header() {

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


+ 42
- 36
admin-client/src/index.js Datei anzeigen

@@ -1,7 +1,13 @@
import React, {useLayoutEffect} from 'react';
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import {BrowserRouter, Navigate, Route, Routes, useLocation} from "react-router-dom";
import {
createBrowserRouter,
createRoutesFromElements, Link,
Navigate, Outlet,
Route,
RouterProvider,
} from "react-router-dom";

import '@fontsource/roboto/300.css';
import '@fontsource/roboto/400.css';
@@ -11,45 +17,45 @@ 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 {InstancesPage} from "./pages/instances-page";
import {NoAccessPage} from "./pages/no-access";
import {InstancesProvider} from "./services/instances/instances-context";
import {AdminLayout} from "./layout";
import {Home} from "@mui/icons-material";

const root = ReactDOM.createRoot(document.getElementById('root'));
const router = createBrowserRouter(
createRoutesFromElements(
<Route path="/" element={<AdminLayout/>} handle={{
crumb: () => <Link to="/"><Home /></Link>
}}>
<Route index element={<InstancesPage/>}/>
<Route path="/no-access" element={<NoAccessPage/>}/>
<Route path="/:instanceId" handle={{
crumb: (routeData) => <span>{routeData?.params?.instanceId}</span>
}}>
<Route index path="" element={<Navigate to="albums"/>} />
<Route path="albums/*" element={<Outlet />} handle={{
crumb: (routeData) => <Link to={`/${routeData?.params?.instanceId}/albums`}>albums</Link>
}}>
<Route index element={<PortfolioManager/>} />
<Route path=":albumId" element={<AlbumManager/>} handle={{
crumb: (routeData) => <Link to={`/${routeData?.params?.instanceId}/albums/${routeData.params.albumId}`}>{routeData.params.albumId}</Link>
}}/>
</Route>
<Route path="signin-keycloak" element={<Navigate to="/"/>}/>
</Route>
<Route path="*" element={<Navigate to={"/no-access"}/>}/>
</Route>
)
);

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>
<Keycloak>
<InstancesProvider>
<RouterProvider router={router} />
</InstancesProvider>
</Keycloak>
</React.StrictMode>
);

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

+ 47
- 16
admin-client/src/layout.js Datei anzeigen

@@ -1,7 +1,7 @@
import * as React from "react";
import Header from "./components/header";
import {useNavigate} from "react-router-dom";
import {useEffect} from "react";
import {Outlet, useLocation, useNavigate, useNavigation} from "react-router-dom";
import {useEffect, useLayoutEffect} from "react";

import "./admin.scss";
import Loader from "./components/Loader";
@@ -10,6 +10,7 @@ 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";
import {Breadcrumb} from "./components/breadcrumb";

const theme = createTheme({
palette: {
@@ -20,10 +21,12 @@ const theme = createTheme({
});


export function AdminLayout({children}) {
export function AdminLayout() {
const navigation = useNavigation();
const {pathname} = useLocation();
const navigate = useNavigate();
const {isLoading, userData, userManager, signIn} = useAuth();
const {isLoading: instancesIsLoading} = useInstances();
const {isLoading: instancesIsLoading, setSelectedInstance, selectedInstance, instances} = useInstances();

useEffect(() => {
async function handleLogin() {
@@ -50,19 +53,47 @@ export function AdminLayout({children}) {
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/>
if (instances && !selectedInstance && setSelectedInstance) {
const possibleSelectedInstance = pathname.split('/')[1];
if (possibleSelectedInstance) {
const selectedInstance = instances?.find(inst => inst.instanceId === possibleSelectedInstance);
if (selectedInstance) {
console.log("We found an instance", selectedInstance);
setSelectedInstance(selectedInstance);
} else {
console.log("We could not detect an instance", possibleSelectedInstance);
navigate("/");
}
</main>
</div>
} else {
setSelectedInstance(null);
}
}
}, [userData, isLoading, userManager, navigate, signIn, setSelectedInstance, selectedInstance, pathname, instances]);

return <ThemeProvider theme={theme}>
<ScrollToTop>
<Header/>
<div id="admin-page">
<main id="page">
<Breadcrumb />
{userData ? <>
{(isLoading || instancesIsLoading || navigation.state === 'loading') && <Loader/>}
{!isLoading && !instancesIsLoading && <Outlet/>}
</>
: <Loader/>
}
</main>
</div>
</ScrollToTop>
</ThemeProvider>
}


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

admin-client/src/pages/instance-selector-page.js → admin-client/src/pages/instances-page.js Datei anzeigen

@@ -1,5 +1,5 @@
import * as React from "react";
import {useRef, useState} from "react";
import {useEffect, 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";
@@ -7,13 +7,13 @@ import {useInstances} from "../services/instances/use-instances";
import {Instance} from "../models/instance";
import {
AddAPhoto, AddCircle,
ArrowRightAltOutlined, RemoveCircle
ArrowRightAltOutlined, Edit, RemoveCircle
} from "@mui/icons-material";

import "./instance-selector-page.scss";
import "./instances-page.scss";
import DragAndDrop from "../components/drag-and-drop/drag-and-drop";

export function InstanceSelectorPage() {
export function InstancesPage() {
const navigate = useNavigate();
const {
instances,
@@ -23,14 +23,14 @@ export function InstanceSelectorPage() {
uploadCoverPhoto,
instanceApiError
} = useInstances();
const [instancesToUpdate, setInstancesToUpdate] = useState(instances?.map(inst => new Instance(inst)) || []);
const [instancesToUpdate, setInstancesToUpdate] = useState(null);
const {userData} = useAuth();

const [newInstanceTitle, setNewInstanceTitle] = useState("");

// useEffect(() => {
// setInstancesToUpdate(instances);
// }, [instances]);
useEffect(() => {
setInstancesToUpdate(instances?.map(inst => new Instance(inst)) || []);
}, [instances]);

let onNewInstanceTitleChanged = (e) => {
setNewInstanceTitle(e.target.value);
@@ -59,14 +59,15 @@ export function InstanceSelectorPage() {
}

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);
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
}));
setNewInstanceTitle("");
}
const handleSaveInstance = async (idx) => {
if (instanceHasChanges(idx)) {
@@ -93,6 +94,11 @@ export function InstanceSelectorPage() {
}

return <div id="instance-selector-page">
<div id="page-title">
<div id="left-part">
<div className="title">Sites</div>
</div>
</div>
{instanceApiError && <div className="error-box">{instanceApiError}</div>}
<div>
{userData?.profile?.resource_access?.photos?.roles?.indexOf("admin") >= 0 && <>
@@ -122,8 +128,11 @@ export function InstanceSelectorPage() {
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`}
? <>
<img src={`${instance.coverPhoto.url}?width=450&height=450&fit=cover`}
alt={instance.coverPhoto.name}/>
<div className="btn-edit-photo"><Edit /></div>
</>
: <AddAPhoto className="no-photos"/>
}
</DragAndDrop>
@@ -162,12 +171,12 @@ export function InstanceSelectorPage() {
)
}}/>
<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>)}
{instance.urls.map(url => <div className="instance-link" key={url}>
<IconButton color="error" onClick={(e) => handleDeleteUrl(e, idx, url)}>
<RemoveCircle/>
</IconButton>
<NavLink to={url}>{url}</NavLink>
</div>)}
</div>
</div>
<div className="input-buttons">

admin-client/src/pages/instance-selector-page.scss → admin-client/src/pages/instances-page.scss Datei anzeigen

@@ -9,6 +9,32 @@

> .instance {

.cover-photo {
img {
position: relative;
}
.btn-edit-photo {
display: none;
}
&:hover {
.btn-edit-photo {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(255, 255, 255, 0.2);
svg {
color: white;
font-size: 48px;
}
}
}
}

.title {
display: flex;

@@ -28,8 +54,10 @@
.instance-links {
margin-top: 10px;
}

div.instance-link {
padding: 2px 0;

a {
color: $accentColor;
text-decoration: none;

+ 0
- 82
admin-client/src/pages/manage-instance-page.js Datei anzeigen

@@ -1,82 +0,0 @@
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>
</>;
}

+ 2
- 1
admin-client/src/pages/portfolio-manager.js Datei anzeigen

@@ -7,7 +7,7 @@ import {
MoveDown,
MoveUp
} from "@mui/icons-material";
import {Link, useNavigate} from "react-router-dom";
import {Link, Outlet, useNavigate} from "react-router-dom";
import Loader from "../components/Loader";

import {Button, TextField} from "@mui/material";
@@ -204,6 +204,7 @@ function PortfolioManager() {
</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} />
<Outlet />
</div>
}


+ 10
- 18
admin-client/src/services/instances/instances-context.js Datei anzeigen

@@ -1,7 +1,6 @@
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(
@@ -9,13 +8,12 @@ export const InstancesContext = React.createContext(
);

export const InstancesProvider = ({children}) => {
const navigate = useNavigate();
const {pathname} = useLocation();
// 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 [instances, setInstances] = useState(/** @type {Instance[]} **/undefined);
const [selectedInstance, setSelectedInstance] = useState(null);

useEffect(() => {
@@ -26,22 +24,10 @@ export const InstancesProvider = ({children}) => {
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]);
}, [userData, instances, fetchInstances]);

const handleCreateInstance = async (instance) => {
const newInstance = await createInstance(instance);
@@ -54,6 +40,12 @@ export const InstancesProvider = ({children}) => {
setInstances([...instances]);
return updatedInstance;
}
const handleDeleteInstance = async (instanceId) => {
await deleteInstance(instanceId);
const idxToRemove = instances.findIndex(i => i.instanceId === instanceId);
instances.splice(idxToRemove, 1)
setInstances([...instances]);
}
const handleUpdateCoverPhoto = async (instanceId, formData) => {
const updatedInstance = await uploadCoverPhoto(instanceId, formData);
const idx = instances.findIndex(i => i.instanceId === updatedInstance.instanceId);
@@ -68,7 +60,7 @@ export const InstancesProvider = ({children}) => {
selectedInstance,
setSelectedInstance,
createInstance: handleCreateInstance,
deleteInstance,
deleteInstance: handleDeleteInstance,
updateInstance: handleUpdateInstance,
uploadCoverPhoto: handleUpdateCoverPhoto,
instanceApiError,


+ 9
- 6
server/src/api/private/albums.js Datei anzeigen

@@ -2,7 +2,6 @@ import {ApiRouter, AuthorizeOptions, HttpDelete, HttpGet, HttpPatch, HttpPost, H
import {BadRequestError, ValidationError} from "../../models/errors/index.js";
import {albums} from "../../services/albums.js";
import multer from "@koa/multer";
import {instances} from "../../services/instances.js";

const upload = multer();
class AlbumsApi extends ApiRouter {
@@ -40,7 +39,8 @@ class AlbumsApi extends ApiRouter {
/**
* @param {KoaRequestContext} ctx
*/
async create({parameters: {instanceId}, body}) {
async create(ctx) {
const {parameters: {instanceId}, body} = ctx;
return {
data: await albums.createAlbum(instanceId, body)
};
@@ -49,7 +49,8 @@ class AlbumsApi extends ApiRouter {
/**
* @param {KoaRequestContext} ctx
*/
async update({parameters: {instanceId, albumId}, body: {name, description, sort}}) {
async update(ctx) {
let {parameters: {instanceId, albumId}, body: {name, description, sort}} = ctx;
sort = sort || "auto";
if (!["manual", "auto"].includes(sort)) {
throw new ValidationError([{
@@ -65,7 +66,8 @@ class AlbumsApi extends ApiRouter {
/**
* @param {KoaRequestContext} ctx
*/
async patch({parameters: {instanceId, albumId}, body}) {
async patch(ctx) {
const {parameters: {instanceId, albumId}, body} = ctx;
return {
data: await albums.patchAlbum(instanceId, albumId, body)
};
@@ -122,9 +124,10 @@ class AlbumsApi extends ApiRouter {
/**
* @param {KoaRequestContext} ctx
*/
async patchPhoto({parameters: {instanceId, albumId}, body}) {
async patchPhoto(ctx) {
const {parameters: {instanceId, albumId, photoId}, body} = ctx;
return {
data: await albums.patchPhoto(instanceId, albumId, parameters.photoId, body)
data: await albums.patchPhoto(instanceId, albumId, photoId, body)
};
}



+ 10
- 1
server/src/api/private/instances.js Datei anzeigen

@@ -1,4 +1,4 @@
import {ApiRouter, AuthorizeOptions, HttpGet, HttpPatch, HttpPost, HttpPut} from "../api-routing/index.js";
import {ApiRouter, AuthorizeOptions, HttpDelete, HttpGet, HttpPatch, HttpPost, HttpPut} from "../api-routing/index.js";
import {instances} from "../../services/instances.js";
import {BadRequestError} from "../../models/errors/index.js";
import {albums} from "../../services/albums.js";
@@ -14,6 +14,7 @@ class InstancesApi extends ApiRouter {
}));
this.registerEndpoint(new HttpGet("/:instanceId", this.fetchInstance));
this.registerEndpoint(new HttpPut("/:instanceId", this.updateInstance));
this.registerEndpoint(new HttpDelete("/:instanceId", this.deleteInstance));
this.registerEndpoint(new HttpPost("/:instanceId/upload-cover-photo", this.uploadPhoto, {
middlewares: [upload.array('cover-photo')]
}
@@ -51,6 +52,14 @@ class InstancesApi extends ApiRouter {
};
}

/**
* @param {KoaRequestContext} ctx
*/
async deleteInstance({parameters, body}) {
await instances.deleteInstance(parameters.instanceId);
return null;
}

/**
* @param {KoaRequestContext} ctx
*/


+ 1
- 1
server/src/services/albums.js Datei anzeigen

@@ -132,7 +132,7 @@ class AlbumsService {
album,
albumToMove
]);
return await this.getAlbums();
return await this.getAlbums(instanceId);
}

/**


+ 8
- 0
server/src/services/instances.js Datei anzeigen

@@ -47,6 +47,14 @@ class InstancesService {
throw new ApiError(err);
}
}
async deleteInstance(instanceId) {
try {
await db.instances.deleteOne({ instanceId });
console.log("Instance deleted", instanceId)
} catch(err) {
throw new ApiError(err);
}
}

/**
* @param {Instance} instance


Laden…
Abbrechen
Speichern