Skip to content

Commit 55ddf0e

Browse files
authored
Merge pull request #48 from ProjectLighthouseCAU/user-role-key-management
Implement Admin management UI
2 parents fe410de + c70f765 commit 55ddf0e

26 files changed

+1608
-171
lines changed

src/components/Display.tsx

Lines changed: 117 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useLayoutEffect, useRef } from 'react';
1+
import React, { useLayoutEffect, useRef, useState } from 'react';
22
import {
33
LIGHTHOUSE_COLOR_CHANNELS,
44
LIGHTHOUSE_COLS,
@@ -7,6 +7,11 @@ import {
77

88
export const DISPLAY_ASPECT_RATIO = 0.8634;
99

10+
export interface MousePos {
11+
x: number;
12+
y: number;
13+
}
14+
1015
export interface DisplayProps {
1116
frame: Uint8Array;
1217
width?: number;
@@ -16,6 +21,10 @@ export interface DisplayProps {
1621
relativeBezelWidth?: number;
1722
relativeGutterWidth?: number;
1823
className?: string;
24+
strictBoundsChecking?: boolean;
25+
onMouseDown?: (p: MousePos) => void;
26+
onMouseUp?: (p: MousePos) => void;
27+
onMouseDrag?: (p: MousePos) => void;
1928
}
2029

2130
export function Display({
@@ -27,9 +36,15 @@ export function Display({
2736
relativeBezelWidth = 0.0183,
2837
relativeGutterWidth = 0.0064,
2938
className,
39+
strictBoundsChecking = false,
40+
onMouseDown = (p: MousePos) => {},
41+
onMouseUp = (p: MousePos) => {},
42+
onMouseDrag = (p: MousePos) => {},
3043
}: DisplayProps) {
3144
const canvasRef = useRef<HTMLCanvasElement>(null);
3245

46+
const [drag, setDrag] = useState(false);
47+
const [prevCoords, setPrevCoords] = useState<number[] | null>(null);
3348
// Set up rendering
3449
useLayoutEffect(() => {
3550
const canvas = canvasRef.current!;
@@ -71,18 +86,111 @@ export function Display({
7186
ctx.fillRect(x, 0, gutterWidth, height);
7287
}
7388

89+
const midPoints: number[][] = [];
7490
// Draw windows
7591
for (let j = 0; j < columns; j++) {
7692
const x = bezelWidth + j * windowWidth + (j + 1) * gutterWidth;
7793

7894
for (let i = 0; i < rows; i++) {
7995
const y = i * (1 + spacersPerRow) * windowHeight;
96+
midPoints.push([x + windowWidth / 2, y + windowHeight / 2]);
8097
const k = (i * LIGHTHOUSE_COLS + j) * LIGHTHOUSE_COLOR_CHANNELS;
8198
const rgb = frame.slice(k, k + LIGHTHOUSE_COLOR_CHANNELS);
8299
ctx.fillStyle = `rgb(${rgb.join(',')})`;
83100
ctx.fillRect(x, y, windowWidth, windowHeight);
84101
}
85102
}
103+
104+
const dist = ([x1, y1]: number[], [x2, y2]: number[]) => {
105+
const xDiff = x1 - x2;
106+
const yDiff = y1 - y2;
107+
return Math.sqrt(xDiff * xDiff + yDiff * yDiff);
108+
};
109+
110+
const mouseToWindowCoords = (mouseCoords: number[]) => {
111+
const closestPointIdx = midPoints
112+
.map(p => dist(p, mouseCoords))
113+
.reduce(
114+
([aIdx, acc], val, idx) => [
115+
val < acc ? idx : aIdx,
116+
Math.min(acc, val),
117+
],
118+
[-1, Infinity]
119+
)[0];
120+
const closestPoint = midPoints[closestPointIdx];
121+
const x = closestPoint[0] - windowWidth / 2;
122+
const y = closestPoint[1] - windowHeight / 2;
123+
if (
124+
strictBoundsChecking &&
125+
!(
126+
mouseCoords[0] >= x &&
127+
mouseCoords[0] <= x + windowWidth &&
128+
mouseCoords[1] >= y &&
129+
mouseCoords[1] <= y + windowHeight
130+
)
131+
) {
132+
return null;
133+
}
134+
const j = Math.round(
135+
(x - bezelWidth - gutterWidth) / (windowWidth + gutterWidth)
136+
);
137+
const i = Math.round(y / (windowHeight * (1 + spacersPerRow)));
138+
return [i, j];
139+
};
140+
141+
const onMouseDownHandler = (event: MouseEvent) => {
142+
setDrag(true);
143+
144+
const rect = canvas.getBoundingClientRect();
145+
const mouseCoords = [event.clientX - rect.left, event.clientY - rect.top];
146+
147+
const windowCoords = mouseToWindowCoords(mouseCoords);
148+
if (!windowCoords) return; // in case of strict bounds checking
149+
setPrevCoords(windowCoords); // for consecutive drag
150+
151+
onMouseDown({ x: windowCoords[1], y: windowCoords[0] });
152+
};
153+
const onMouseUpHandler = (event: MouseEvent) => {
154+
setDrag(false);
155+
156+
const rect = canvas.getBoundingClientRect();
157+
const mouseCoords = [event.clientX - rect.left, event.clientY - rect.top];
158+
159+
const windowCoords = mouseToWindowCoords(mouseCoords);
160+
if (!windowCoords) return; // in case of strict bounds checking
161+
setPrevCoords(windowCoords); // for consecutive drag
162+
163+
onMouseUp({ x: windowCoords[1], y: windowCoords[0] });
164+
};
165+
166+
const onMouseDragHandler = (event: MouseEvent) => {
167+
if (!drag) return;
168+
const rect = canvas.getBoundingClientRect();
169+
const mouseCoords = [event.clientX - rect.left, event.clientY - rect.top];
170+
171+
const windowCoords = mouseToWindowCoords(mouseCoords);
172+
if (!windowCoords) return; // in case of strict bounds checking
173+
174+
// don't emit drag events if coords haven't changed
175+
if (
176+
prevCoords &&
177+
prevCoords[0] === windowCoords[0] &&
178+
prevCoords[1] === windowCoords[1]
179+
) {
180+
return;
181+
}
182+
setPrevCoords(windowCoords);
183+
onMouseDrag({ x: windowCoords[1], y: windowCoords[0] });
184+
};
185+
canvas.style.cursor = 'crosshair';
186+
canvas.addEventListener('mousedown', onMouseDownHandler);
187+
canvas.addEventListener('mousemove', onMouseDragHandler);
188+
canvas.addEventListener('mouseup', onMouseUpHandler);
189+
return () => {
190+
canvas.removeEventListener('mousedown', onMouseDownHandler);
191+
canvas.removeEventListener('mousemove', onMouseDragHandler);
192+
canvas.removeEventListener('mouseup', onMouseUpHandler);
193+
};
86194
}, [
87195
customWidth,
88196
aspectRatio,
@@ -91,6 +199,14 @@ export function Display({
91199
columns,
92200
relativeBezelWidth,
93201
relativeGutterWidth,
202+
strictBoundsChecking,
203+
setDrag,
204+
drag,
205+
setPrevCoords,
206+
prevCoords,
207+
onMouseDown,
208+
onMouseUp,
209+
onMouseDrag,
94210
]);
95211

96212
return <canvas ref={canvasRef} className={className} />;

src/components/UserAddModal.tsx

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { AuthContext } from '@luna/contexts/api/auth/AuthContext';
2+
import { newUninitializedUser, User } from '@luna/contexts/api/auth/types';
3+
import { CreateOrUpdateUserPayload } from '@luna/contexts/api/auth/types/CreateOrUpdateUserPayload';
4+
import {
5+
Button,
6+
Checkbox,
7+
Input,
8+
Modal,
9+
ModalBody,
10+
ModalContent,
11+
ModalFooter,
12+
ModalHeader,
13+
} from '@nextui-org/react';
14+
import { useCallback, useContext, useEffect, useState } from 'react';
15+
16+
export interface UserAddModalProps {
17+
isOpen: boolean;
18+
setOpen: (show: boolean) => void;
19+
}
20+
21+
export function UserAddModal({ isOpen, setOpen }: UserAddModalProps) {
22+
const [user, setUser] = useState<User>(newUninitializedUser());
23+
const [password, setPassword] = useState('');
24+
25+
// initialize/reset modal state
26+
useEffect(() => {
27+
if (!isOpen) return;
28+
setUser(newUninitializedUser());
29+
setPassword('');
30+
}, [isOpen]);
31+
32+
const auth = useContext(AuthContext);
33+
34+
const addUser = useCallback(async () => {
35+
const payload: CreateOrUpdateUserPayload = {
36+
username: user.username,
37+
password,
38+
email: user.email,
39+
permanent_api_token: user.permanentApiToken,
40+
};
41+
const result = await auth.createUser(payload);
42+
if (result.ok) {
43+
console.log('added user:', payload);
44+
} else {
45+
console.log('failed to add user:', result.error);
46+
}
47+
// TODO: UI feedback from the request (success, error)
48+
setOpen(false);
49+
}, [setOpen, user, password, auth]);
50+
51+
return (
52+
<Modal isOpen={isOpen} onOpenChange={setOpen}>
53+
<ModalContent>
54+
{onClose => (
55+
<>
56+
<ModalHeader>Add User</ModalHeader>
57+
<ModalBody>
58+
<Input
59+
label="Username"
60+
value={user.username}
61+
onValueChange={username => {
62+
if (!user) return;
63+
setUser({ ...user, username });
64+
}}
65+
/>
66+
<Input
67+
type="password"
68+
label="Password"
69+
value={password}
70+
onValueChange={setPassword}
71+
/>
72+
<Input
73+
label="E-Mail"
74+
value={user.email}
75+
onValueChange={email => {
76+
if (!user) return;
77+
setUser({ ...user, email });
78+
}}
79+
/>
80+
<Checkbox
81+
isSelected={user.permanentApiToken}
82+
onValueChange={permanentApiToken => {
83+
if (!user) return;
84+
setUser({
85+
...user,
86+
permanentApiToken,
87+
});
88+
}}
89+
>
90+
Permanent API Token
91+
</Checkbox>
92+
</ModalBody>
93+
<ModalFooter>
94+
<Button color="success" onPress={addUser}>
95+
Add
96+
</Button>
97+
<Button onPress={onClose}>Cancel</Button>
98+
</ModalFooter>
99+
</>
100+
)}
101+
</ModalContent>
102+
</Modal>
103+
);
104+
}

src/components/UserDeleteModal.tsx

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { AuthContext } from '@luna/contexts/api/auth/AuthContext';
2+
import { newUninitializedUser, User } from '@luna/contexts/api/auth/types';
3+
import {
4+
Button,
5+
Checkbox,
6+
Input,
7+
Modal,
8+
ModalBody,
9+
ModalContent,
10+
ModalFooter,
11+
ModalHeader,
12+
} from '@nextui-org/react';
13+
import { useCallback, useContext, useEffect, useState } from 'react';
14+
15+
export interface UserDeleteModalProps {
16+
id: number;
17+
isOpen: boolean;
18+
setOpen: (open: boolean) => void;
19+
}
20+
21+
export function UserDeleteModal({ id, isOpen, setOpen }: UserDeleteModalProps) {
22+
const [user, setUser] = useState<User>(newUninitializedUser());
23+
24+
const auth = useContext(AuthContext);
25+
26+
// initialize modal state
27+
useEffect(() => {
28+
if (!isOpen) return;
29+
30+
const fetchUser = async () => {
31+
const userResult = await auth.getUserById(id);
32+
if (userResult.ok) {
33+
setUser(userResult.value);
34+
} else {
35+
console.log('Fetching user failed:', userResult.error);
36+
setUser(newUninitializedUser());
37+
}
38+
};
39+
fetchUser();
40+
}, [id, isOpen, auth]);
41+
42+
const deleteUser = useCallback(() => {
43+
console.log('deleting user with id', id);
44+
// TODO: call DELETE /users/<id>
45+
// TODO: feedback from the request (success, error)
46+
setOpen(false);
47+
}, [id, setOpen]);
48+
49+
return (
50+
<Modal isOpen={isOpen} onOpenChange={setOpen}>
51+
<ModalContent>
52+
{onClose => (
53+
<>
54+
<ModalHeader>Delete User</ModalHeader>
55+
<ModalBody>
56+
<Input label="ID" value={id.toString()} isDisabled />
57+
58+
<Input
59+
label="Username"
60+
value={user.username}
61+
onValueChange={username => {
62+
if (!user) return;
63+
setUser({ ...user, username });
64+
}}
65+
isDisabled
66+
/>
67+
<Input
68+
label="E-Mail"
69+
value={user.email}
70+
onValueChange={email => {
71+
if (!user) return;
72+
setUser({ ...user, email });
73+
}}
74+
isDisabled
75+
/>
76+
<Input
77+
label="Created At"
78+
value={user.createdAt.toLocaleString()}
79+
isDisabled
80+
/>
81+
<Input
82+
label="Updated At"
83+
value={user.updatedAt.toLocaleString()}
84+
isDisabled
85+
/>
86+
<Input
87+
label="Last Login"
88+
value={user.lastSeen.toLocaleString()}
89+
isDisabled
90+
/>
91+
<Checkbox
92+
isSelected={user.permanentApiToken}
93+
onValueChange={permanentApiToken => {
94+
if (!user) return;
95+
setUser({
96+
...user,
97+
permanentApiToken,
98+
});
99+
}}
100+
isDisabled
101+
>
102+
Permanent API Token
103+
</Checkbox>
104+
</ModalBody>
105+
<ModalFooter>
106+
<Button color="danger" onPress={deleteUser}>
107+
Delete
108+
</Button>
109+
<Button onPress={onClose}>Cancel</Button>
110+
</ModalFooter>
111+
</>
112+
)}
113+
</ModalContent>
114+
</Modal>
115+
);
116+
}

0 commit comments

Comments
 (0)