Implement admin-ui prototype
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
Dominic Zimmer 2022-10-11 17:52:07 +02:00
parent 9e80ab2b41
commit c31a6632b4
7 changed files with 507 additions and 16655 deletions

16691
package-lock.json generated

File diff suppressed because it is too large Load Diff

184
src/AdminUI.css Normal file
View File

@ -0,0 +1,184 @@
@media only screen and (max-width: 600px) {
.content {
padding: 5px !important;
}
span.lg {
font-size: 12pt!important;
}
}
.content {
/*background-color: grey;*/
padding: 100px 50px;
display: grid;
grid-template-areas: "hd-1 hd-2 hd-3" "body body body";
grid-template-columns: auto 10fr auto;
grid-template-rows: auto 1fr;
gap: 5px;
}
.content > div {
border-radius: 5px;
padding: 5px;
background-color: lightgray;
}
.content > div > span {
vertical-align: middle;
}
.create-party-label {
grid-area: hd-3;
font-size: 16pt;
border-radius: 20px;
cursor: pointer;
background-color: darkgray!important ;
}
.select-party-label {
grid-area: hd-1;
font-size: 16pt;
border-radius: 20px;
}
.select-party-box {
overflow: scroll;
grid-area: hd-2;
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
gap: 5px;
}
.select-party {
background-color: darkgray;
border-radius: 4px;
padding: 4px;
cursor: pointer;
user-select: none;
font-size: 16pt;
}
.select-party.selected {
background-color: rgb(255, 189, 67);
}
.select-party > span {
color: white;
}
.party-box {
grid-area: body;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: center;
padding: 10px !important;
gap: 5px;
}
.guests {
display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr;
gap: 5px 0px;
overflow: auto;
}
.guests .header {
background-color: unset!important;
justify-self: center;
justify-content: center;
display: flex;
flex-direction: column;
align-items: center;
}
.confirmations > span:not(:last-child) {
margin-right: 5px;
}
.confirmations > span:nth-child(1) {
color: green;
font-weight: bold;
}
.confirmations > span:nth-child(2) {
color: red;
font-weight: bold;
}
.confirmations > span:nth-child(3) {
color: black;
font-weight: bold;
}
span.lg {
font-size: 18pt;
}
.guests > div {
background-color: lightgrey!important ;
padding: 8px;
cursor: pointer;
overflow: hidden;
}
.guests>div:nth-child(8n+5),
.guests>div:nth-child(8n+6),
.guests>div:nth-child(8n+7),
.guests>div:nth-child(8n+8) {
background-color: #c5c5c5!important;
}
.guests>div:nth-child(8n+9),
.guests>div:nth-child(8n+10),
.guests>div:nth-child(8n+11),
.guests>div:nth-child(8n+12) {
background-color: #dedede!important;
;
}
.partyname {
font-size: 24pt;
}
dialog {
top: 50%;
transform: translateY(-50%);
border-radius: 10px;
}
dialog>div{
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
}
dialog > div label {
margin-right: 5px;
}
#backdrop {
background-color: black;
opacity: 0.3;
width: 100vw;
height: 100vh;
position: absolute;
top: 0px;
left:0px;
}
.dialog-table {
display: grid;
gap: 5px;
grid-template-columns: 1fr 1fr;
}
.add-guest-button {
padding: 5px;
border-radius: 4px;
background-color: darkgray;
cursor: pointer;
}
.add-guest {
display: flex;
width: 100%;
justify-content: space-between;
}
.add-guest > .tip {
align-self: flex-end;
font-size: 8pt;
color: gray;
}
.dialog-bottom {
display:flex;
flex-direction: row;
justify-content: space-around;
width:100%;
}

181
src/AdminUI.tsx Normal file
View File

@ -0,0 +1,181 @@
import React, { Fragment, useCallback, useEffect, useMemo, useState } from 'react';
import './AdminUI.css';
import { createGuestRequest, deleteGuestRequest, listGuestsRequest, listPartyRequest, modifyGuestRequest, parseToken } from './partyAdminApi';
import { GrammaticalGender, Person, RequestCreateGuest, ResponseCreateParty, ResponseListGuests, ResponseListParties } from './partyAdminApiTypes';
export const AdminUI: React.FC = () => {
const [partyList, setPartyList] = useState<ResponseListParties>([]);
const [selectedParty, setSelectedParty] = useState<number | undefined>(undefined);
// eslint-disable-next-line no-restricted-globals
const adminToken = useMemo(() => parseToken(location.href) ?? "666c7199d0d1a3a90c5b10cf6fff364eb04eeffa8c76c82541a55213338ce983", []);
const loadPartyList = useCallback(async () => {
const response = await listPartyRequest(adminToken);
if (selectedParty === undefined)
setSelectedParty(response.length > 0 ? 0 : undefined);
setPartyList(response);
}, [adminToken, selectedParty]);
const deleteUser = async () => {
if (selectedParty === undefined || !partyList[selectedParty]) return;
if (!editUserData || (!("_id" in editUserData))) return;
deleteGuestRequest(adminToken, partyList[selectedParty].name, editUserData);
loadPartyList();
dismissBackdrop();
};
const editUser = (user?: RequestCreateGuest) => {
const dialog = document.getElementById("dialog-edit-user") as HTMLDialogElement;
if (!dialog) return;
setEditUserData({ coming: null, extra: {}, grammatical_gender: "d", name: "", token: "", ...user });
dialog.show();
};
const [editUserData, setEditUserData] = useState<RequestCreateGuest|Person>();
const dismissBackdrop = () => {
const dialog = document.getElementById("dialog-edit-user") as HTMLDialogElement;
setEditUserData(undefined);
dialog.close();
};
const saveEditUser = () => {
if (selectedParty === undefined || !partyList[selectedParty]) return;
if (!editUserData) return;
if ("_id" in editUserData) {
modifyGuestRequest(adminToken, partyList[selectedParty].name, editUserData);
} else {
createGuestRequest(adminToken, partyList[selectedParty].name, editUserData);
}
loadPartyList();
dismissBackdrop();
};
useEffect(() => {
if (adminToken === "") return;
loadPartyList();
}, [adminToken, loadPartyList]);
return <>
{editUserData ?
<div id="backdrop" onClick={dismissBackdrop} />
: null}
<dialog id="dialog-edit-user">
<div>
{editUserData ?
<>
<span className="lg">{"_id" in editUserData ? `Editing ${editUserData.name}`: "New Invitation"}</span>
<div className="dialog-table">
<label htmlFor="edit-token">Token</label>
<input type="text" id="edit-token" value={editUserData.token} onChange={(e) => { setEditUserData({ ...editUserData, token: e.target.value }) }} />
<label htmlFor="edit-name">Name</label>
<input type="text" id="edit-name" value={editUserData.name} onChange={(e) => { setEditUserData({ ...editUserData, name: e.target.value }) }} />
<label htmlFor="edit-coming">Coming</label>
<select id="edit-coming" value={editUserData.coming ?? "null"} onChange={(e) => {
const choice = e.target.value as "yes" | "no" | "maybe" | "null";
setEditUserData({ ...editUserData, coming: (choice === "null" ? null : choice) });
}} >
<option value="yes">yes</option>
<option value="no">no</option>
<option value="maybe">maybe</option>
<option value="null"></option>
</select>
<label htmlFor="edit-gender">Gender</label>
<select id="edit-gender" value={editUserData.grammatical_gender} onChange={(e) => {
const choice = e.target.value as GrammaticalGender
setEditUserData({ ...editUserData, grammatical_gender: choice });
}} >
<option value="m">m</option>
<option value="f">f</option>
<option value="d">d</option>
</select>
</div>
</>
: "Hab keine user data bekommen."}
<div className="dialog-bottom">
<button onClick={() => { deleteUser() }}>Delete</button>
<button onClick={() => { saveEditUser() }}>Save</button>
</div>
</div>
</dialog>
<div className="content">
<div className="select-party-label">
<span>
Select a party
</span>
</div>
<div className="select-party-box">
{partyList.map((p, index) =>
<div className={`select-party ${index === selectedParty ? "selected" : ""}`}>
<span onClick={() => setSelectedParty(index)} key={index}>{p.name}</span>
</div>
)}
</div>
<div title="Create New Party" className="create-party-label">
<span>
🎉
</span>
</div>
<div className="party-box">
{selectedParty !== undefined ?
<PartyUI party={partyList[selectedParty]} adminToken={adminToken} editUser={editUser} />
: null}
</div>
</div>
</>
};
export const PartyUI: React.FC<{ party: ResponseCreateParty, adminToken: string, editUser: (user: RequestCreateGuest) => void }> = ({ party, adminToken, editUser }) => {
const [guests, setGuests] = useState<ResponseListGuests>([]);
const loadGuests = useCallback(async () => {
const guests = await listGuestsRequest(adminToken, party.name);
setGuests(guests);
}, [adminToken, party.name]);
const confirmations = useMemo<[number, number, number]>(() => [
guests.filter(g => g.coming === "yes").length,
guests.filter(g => g.coming === "no").length,
guests.filter(g => g.coming === null || g.coming === "maybe").length,
], [guests]);
useEffect(() => {
loadGuests();
}, [loadGuests, party]);
const exportPartyLink = async (user: RequestCreateGuest) => {
const url = `https://${party.name}.party.leafbla.de/${user.token}`;
navigator.clipboard.writeText(url);
};
return <>
<div>
<div className="guests">
<div className="header"><span className="lg">Token </span></div>
<div className="header"><span className="lg">Name </span></div>
<div className="header"><span className="lg">Gender</span></div>
<div className="header"><span className="lg">Coming</span><div className="confirmations">
<span>{confirmations[0]}</span>
<span>{confirmations[1]}</span>
<span>{confirmations[2]}</span>
</div></div>
{guests.map((guest, index) => <Fragment key={index}>
<div onContextMenu={(e) => { e.preventDefault(); exportPartyLink(guest); }} onClick={() => editUser(guest)}>{guest.token}</div>
<div onContextMenu={(e) => { e.preventDefault(); exportPartyLink(guest); }} onClick={() => editUser(guest)}>{guest.name}</div>
<div onContextMenu={(e) => { e.preventDefault(); exportPartyLink(guest); }} onClick={() => editUser(guest)}>{guest.grammatical_gender}</div>
<div onContextMenu={(e) => { e.preventDefault(); exportPartyLink(guest); }} onClick={() => editUser(guest)}>{guest.coming ?? "?"}</div>
</Fragment>
)}
</div>
</div>
<div className="add-guest">
<span className="tip">Did you know: Contextmenu copies the invite link!</span>
<span className="lg add-guest-button" onClick={() => editUser({ coming: null, extra: {}, grammatical_gender: "m", name: "", token: "" })}>
🏃
</span>
</div>
</>;
};

View File

@ -1,19 +1,13 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { AdminUI } from './AdminUI';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<React.StrictMode>
<App />
<AdminUI/>
</React.StrictMode>
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

55
src/partyAdminApi.ts Normal file
View File

@ -0,0 +1,55 @@
import { Person, RequestCreateGuest, RequestCreateParty, ResponseCreateParty, ResponseListGuests, ResponseListParties } from "./partyAdminApiTypes";
export const parseToken = (uri: string): string | undefined => {
const x = uri.match(/https?:\/\/party\.leafbla\.de\/(?<token>.+)/);
if (x === null || x.groups === undefined) return;
const token = x.groups["token"];
if (!token) return;
return token;
};
const apiUrl = (adminToken : string): string => {
return `https://party.leafbla.de/api/${adminToken}`;
};
export const listPartyRequest = async (adminToken: string): Promise<ResponseListParties> => {
const result = await fetch(`${apiUrl(adminToken)}`, { method: "get" });
if (!result.ok) throw new Error("Error sending listPartyRequest");
const data = await result.json();
return data as ResponseListParties;
};
export const createPartyRequest = async (adminToken: string, payload: RequestCreateParty): Promise<ResponseCreateParty> => {
const result = await fetch(`${apiUrl(adminToken)}`, { method: "post", body: JSON.stringify(payload) });
if (!result.ok) throw new Error("Error sending createPartyRequest");
const data = await result.json();
return data as ResponseCreateParty;
};
export const listGuestsRequest = async (adminToken: string, partyName: string): Promise<ResponseListGuests> => {
const result = await fetch(`${apiUrl(adminToken)}/${partyName}`, { method: "get" });
if (!result.ok) throw new Error("Error sending listGuestRequest");
const data = await result.json();
return data as ResponseListGuests;
};
export const createGuestRequest = async (adminToken: string, partyName: string, payload: RequestCreateGuest): Promise<Person> => {
const result = await fetch(`${apiUrl(adminToken)}/${partyName}`, { method: "post", body: JSON.stringify(payload), headers: { "Content-Type": "application/json" } });
if (!result.ok) throw new Error("Error sending createGuestRequest");
const data = await result.json();
return data as Person;
};
export const modifyGuestRequest = async (adminToken: string, partyName: string, payload: Person): Promise<Person> => {
const result = await fetch(`${apiUrl(adminToken)}/${partyName}/${payload._id}`, { method: "PATCH", body: JSON.stringify(payload), headers: { "Content-Type": "application/json" } });
if (!result.ok) throw new Error("Error sending createGuestRequest");
const data = await result.json();
return data as Person;
};
export const deleteGuestRequest = async (adminToken: string, partyName: string, payload: Person): Promise<Person> => {
const result = await fetch(`${apiUrl(adminToken)}/${partyName}/${payload._id}`, { method: "DELETE", headers: { "Content-Type": "application/json" } });
if (!result.ok) throw new Error("Error sending createGuestRequest");
const data = await result.json();
return data as Person;
};

37
src/partyAdminApiTypes.ts Normal file
View File

@ -0,0 +1,37 @@
export type ResponseListParties = ResponseCreateParty[];
export type ResponseCreateParty = {
"_id": string,
name: string,
created: string,
allowedExtra: AllowedExtraLengths,
}
export type AllowedExtraLengths = { [key: string]: number };
export type RequestCreateParty = {
name: "string",
"allowed_extra": AllowedExtraLengths,
}
export type ResponseListGuests = Person[];
export type GrammaticalGender = "m" | "f" | "d";
export type ComingStatus = "yes" | "no" | "maybe" | null;
export type Person = {
"_id": string,
token: string,
name: string,
coming: ComingStatus,
"grammatical_gender": GrammaticalGender,
extra: Record<string, string>,
};
export type RequestCreateGuest = {
token: string,
name: string,
coming: ComingStatus,
"grammatical_gender": GrammaticalGender,
extra: Record<string, string>,
};