generated from partypages/party-template
Implement admin-ui prototype
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
This commit is contained in:
parent
9e80ab2b41
commit
c31a6632b4
16691
package-lock.json
generated
16691
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
184
src/AdminUI.css
Normal file
184
src/AdminUI.css
Normal 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
181
src/AdminUI.tsx
Normal 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>
|
||||
</>;
|
||||
};
|
@ -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
55
src/partyAdminApi.ts
Normal 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
37
src/partyAdminApiTypes.ts
Normal 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>,
|
||||
};
|
Loading…
Reference in New Issue
Block a user