initial commit

This commit is contained in:
Soph :3 2024-05-22 11:35:43 +03:00
commit 6f5a39c212
Signed by: sophie
GPG key ID: EDA5D222A0C270F2
47 changed files with 3601 additions and 0 deletions

2
web/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
node_modules
dist

BIN
web/bun.lockb Executable file

Binary file not shown.

24
web/index.html Normal file
View file

@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark" />
<title>Trillium</title>
<script src="https://kit.fontawesome.com/f8dad62a29.js" crossorigin="anonymous"></script>
<link rel="icon" type="image/svg+xml" href="/logo.svg">
<style>
.grecaptcha-badge {
visibility: hidden !important;
}
</style>
</head>
<body>
<div id="app" class="dark"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>

33
web/package.json Normal file
View file

@ -0,0 +1,33 @@
{
"private": true,
"name": "web",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@elysiajs/eden": "^1.0.13",
"@fortawesome/fontawesome-free": "^6.5.2",
"autoprefixer": "^10.4.19",
"gif-picker-react": "^1.3.2",
"preact": "^10.20.0",
"react-google-recaptcha-v3": "^1.10.1",
"react-hot-toast": "^2.4.1",
"react-use-websocket": "^4.8.1"
},
"devDependencies": {
"@preact/preset-vite": "^2.8.2",
"@types/react-google-recaptcha": "^2.1.9",
"postcss": "^8.4.38",
"tailwindcss": "^3.4.3",
"vite": "^5.2.2"
},
"eslintConfig": {
"extends": "preact"
},
"trustedDependencies": [
"@fortawesome/fontawesome-free"
]
}

6
web/postcss.config.js Normal file
View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

20
web/public/logo.svg Normal file
View file

@ -0,0 +1,20 @@
<svg xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 136 132" version="1.1">
<path
d="M 11.238283,77.871644 C 9.6359691,77.442305 7.2871274,85.365019 6.930159,89.268215 l 0.09375,12.531255 c 0,8.24363 -5.7560452,8.26497 -6.06249996,10.09375 -1.04518504,4.19882 7.06232836,3.71699 14.78969296,3.62907 15.092131,-0.17172 26.147807,-6.10331 26.147807,-7.94157 0,-4.69512 -2.384992,-4.84425 -3.625,-9.968755 -1.48773,-6.148245 -0.809437,-12.718987 -4.384679,-12.125 -3.029969,0.503396 -7.255084,-0.229024 -10.676538,-1.067985 -5.854236,-1.435495 -9.868194,-5.982978 -11.974409,-6.547336 z m 12.523856,20.133443 c -3.173125,3.366313 -3.280867,5.186763 -6.498471,4.006973 -2.574573,-0.94401 -0.80779,-3.617506 1.517064,-7.317657 1.953596,-3.529986 5.146861,-3.669661 6.792852,-2.880987 2.549064,1.221381 1.36168,2.825359 -1.811445,6.191671 z"
style= "fill:rgb(229, 231, 235);fill-opacity:1;stroke:none" />
<path
d="M 71.371805,24.042853 C 72.38763,22.731426 65.990638,17.500276 62.529508,15.660996 59.019722,14.053831 55.668618,12.393772 50.643472,11.291727 43.268106,8.3286412 45.55116,2.4014777 43.975669,1.4036601 39.925542,-2.2851948 39.159793,4.383057 37.164116,14.324443 c -7.721778,14.391503 -7.065692,25.850088 -5.050114,26.4972 4.470374,1.435237 5.389738,-0.325068 10.594919,0.51411 6.245042,1.006824 11.256659,6.529478 12.092155,3.002848 0.708073,-2.988771 3.766673,-9.038409 6.194855,-11.590688 4.44462,-4.671769 9.45993,-6.897341 10.375874,-8.70506 z m -28.184982,2.686114 c -1.336242,-4.928584 -1.366585,-8.453652 0.317312,-8.904851 2.125118,-0.569423 2.863597,2.796957 4.552804,7.229228 2.206003,5.78828 5.035336,8.060584 3.604424,9.162751 -1.883975,1.451141 -7.138298,-2.558544 -8.47454,-7.487128 z"
style= "fill:rgb(229, 231, 235);fill-opacity:1;stroke:none" />
<path
d="m 109.33641,55.830715 c -0.11034,0.01299 -0.21667,0.04389 -0.28125,0.125 -1.36236,1.710966 -3.1317,7.452006 -8.03125,11.03125 -2.844616,2.078062 -7.308728,5.293317 -10.266888,6.120006 -3.689801,1.031154 0.709128,5.964975 2.985019,11.960616 1.871085,4.929213 -1.929553,8.043403 1.753759,10.955004 2.347541,1.855695 16.29852,0.378895 25.38245,-6.113197 4.36016,-2.808938 12.49947,-1.182006 14.08497,-5.585759 1.5855,-4.403753 -8.57491,-1.080782 -11.87681,-8.52417 l -4.46499,-11.276874 c -1.86595,-3.165473 -7.62997,-8.886706 -9.28501,-8.691876 z m 8.52008,23.380046 c -0.36078,2.770582 -6.16422,-2.317005 -10.01431,-3.044842 -4.01357,-0.758742 -10.45728,2.584598 -8.586742,-2.132375 1.870542,-4.716974 19.733412,-2.512878 18.601052,5.177217 z m -11.69646,-0.396999 c 1.28677,-0.03378 3.47878,0.704378 4.61388,1.329449 4.5404,2.500282 1.87736,4.765754 0.65625,5.209998 -1.22111,0.444243 -1.65698,-0.80362 -5.15625,-1.741248 -6.24596,-1.6736 -3.97419,-4.696847 -0.11388,-4.798199 z"
style= "fill:rgb(229, 231, 235);fill-opacity:1;stroke:none" />
<path
d="M 7.7923024,42.45318 C -2.0077829,65.105972 26.71296,89.696127 39.898909,78.861965 c 6.589995,-12.277406 13.55474,-12.860372 16.40625,-14.125 0.740344,-0.328338 1.139952,-4.761046 4.1875,-5.6875 C 57.283368,53.317038 47.93139,45.388652 36.511142,45.388652 c -5.46819,0 -7.887478,3.97375 -17.00789,4.223313 C 7.8224381,49.931588 9.3031945,41.562351 7.7923024,42.45318 z m 2.9503566,11.283785 c 1.336629,-2.315109 7.039156,3.971663 18.34375,1.78125 12.046196,-2.334108 19.104142,12.252633 14.34375,11.75 C 36.80316,65.420819 37.398401,60.17302 28.711409,60.705715 18.25717,62.45766 9.2181591,58.865339 10.742659,53.736965 z"
style= "fill:rgb(100, 116, 139);fill-opacity:1;stroke:none" />
<path
d="m 92.02952,26.186975 c -9.125945,0 -15.511413,1.307991 -22.050533,5.416762 -8.050582,5.05848 -14.380046,13.510553 -10.330078,18.445728 6.01863,8.952383 12.21091,11.683583 13.15625,14.65625 1.024544,3.22173 -0.287197,5.399564 -0.915784,6.4616 -13.200216,8.806877 -11.472751,-2.915979 -16.802966,-1.36785 -12.231867,4.082598 -13.837875,15.230592 -11.0625,29.4375 1.175052,6.014995 6.848222,11.962175 11.40625,14.593755 4.558027,2.63158 12.26692,12.14989 8.53125,13.21875 -3.73567,1.06885 -3.913221,5.30111 0.34375,4.46875 4.256971,-0.83235 13.389958,-6.79471 18.84375,-14.0625 12.120315,-16.17878 6.988356,-45.114941 -3.21875,-44.218755 -1.946273,0.170884 -2.393987,-0.266654 -3.965837,-1.191472 -1.066665,-1.066666 0.17251,-3.962407 1.034428,-3.464778 8.075512,4.290847 17.606739,2.020003 23.77516,-7.125 3.27327,-4.852793 6.71183,-11.988102 4.84375,-20.34375 1.60329,-9.459235 9.33591,-5.755361 9.96875,-9.03125 -6.32119,-4.829512 -13.69944,-5.89374 -23.55689,-5.89374 z m -3.818111,6.67499 c 0.643748,0.03294 1.226158,0.288658 1.71875,0.78125 2.627157,2.627156 -5.109856,6.556107 -10.071252,11.517504 -4.961397,4.961396 -7.111819,13.097306 -10.5,11.03125 -3.282729,-2.001753 1.586049,-10.141425 7.375,-15.375 4.582,-4.142415 8.687926,-8.097743 11.477502,-7.955004 z m 6.40625,7.9375 c 3.813127,2.551966 -1.104534,4.705905 -3.026874,6.370624 -1.922341,1.664718 -4.058892,5.929572 -6.548754,3.481878 -4.041067,-7.397924 10.321718,-11.60946 9.575628,-9.852502 z m -1.625,10.65625 c 2.763568,-0.238557 1.756747,4.054721 0.28125,4.4375 -1.815996,0.471113 -5.426439,3.57716 -6.0625,2.5625 -0.636061,-1.01466 -1.540412,-3.676616 3.21875,-6 1.079909,-0.639475 1.924754,-0.944948 2.5625,-1 z m -23.65625,24.25 c 3.662476,0.02877 7.698919,5.806581 8.410935,18.673337 0.702975,12.703388 -1.093993,19.949208 -5.727143,22.322948 -5.408753,-0.577 1.232818,-5.83521 0.552172,-22.47298 -0.512636,-12.530912 -8.234635,-17.625405 -4.481727,-18.17982 0.116865,-0.01726 1.127619,-0.344413 1.245763,-0.343485 z m 11.5,4.78125 c 1.909349,-0.0278 3.33756,3.625376 3.78125,5.28125 0.764187,2.851984 1.679071,6.812796 1,10.4375 -0.795494,4.961315 -4.950395,1.748252 -4.804416,-4.203283 0.14198,-5.788552 -3.95109,-9.436322 -0.820584,-11.265467 0.290641,-0.16982 0.570986,-0.246028 0.84375,-0.25 z m -20.625,1.375 c 4.129893,0.3207 7.680609,16.713233 4.5625,20.875005 -0.235125,0.28828 -0.506327,0.52533 -0.78125,0.75 -3.897922,3.1854 -4.046108,-4.371026 -3.78125,-7.250005 0.313874,-3.411786 -3.81232,-12.40589 -0.40625,-14.34375 0.134237,-0.02406 0.273028,-0.0416 0.40625,-0.03125 z"
style= "fill:rgb(100, 116, 139);fill-opacity:1;stroke:none" />
<path
d="m 62.878316,69.141267 c -1.73286,-1.09783 -1.666444,-3.322247 -0.516173,-5.196413 1.150271,-1.874167 2.816894,-1.773196 4.779929,-0.290318 1.963034,1.482877 1.193223,3.534175 0.274458,4.798645 -0.918765,1.264469 -2.805353,1.785916 -4.538214,0.688086 z"
style= "fill:rgb(100, 116, 139);fill-opacity:1;stroke:none" />
</svg>

After

Width:  |  Height:  |  Size: 6.5 KiB

132
web/src/components/App.tsx Normal file
View file

@ -0,0 +1,132 @@
import Chat from "./Chat";
import ChatBar from "./ChatBar";
import Sidebar from "./Sidebar";
import useWebSocket from 'react-use-websocket';
import { useEffect, useState } from "preact/hooks";
import toast, { Toaster } from 'react-hot-toast';
import People from "./People";
import { apiDomain } from "../treaty";
export default function App() {
// #region State handling
const [socketUrl, setSocketUrl] = useState(apiDomain.replace("http", "ws") + '/api/ws/');
const [roomName, setRoomName] = useState(localStorage.lastRoom);
const [sendRoomHidden, setSendRoomHidden] = useState<boolean>(localStorage.lastHidden == "true");
const [accountState, setAccountState] = useState<undefined | {
username: string;
}>(undefined)
const [staff, setStaff] = useState<boolean>(false);
const [rooms, setRooms] = useState([]);
const [messageHistory, setMessageHistory] = useState<{
userID: string;
content: string;
images?: string[];
time: number;
username: string;
}[]>([]);
const [people, setPeople] = useState([]);
const [user, setUser] = useState<any>({});
const [currentPeopleView, setCurrentPeopleView] = useState("people");
// #endregion State handling
const { sendMessage, lastMessage, readyState } = useWebSocket(socketUrl, {
shouldReconnect: (closeEvent) => {
if (accountState == undefined) return false;
return true;
},
});
useEffect(() => {
if (sendRoomHidden) localStorage.lastHidden = "true";
else localStorage.lastHidden = "false";
sendMessage(JSON.stringify({
type: "room",
room: roomName,
hidden: sendRoomHidden
}))
}, [roomName, sendRoomHidden])
useEffect(() => {
if (lastMessage !== null) {
let data;
try {
data = JSON.parse(lastMessage.data);
} catch {
return;
}
if (data.type == "hello") {
toast.success("Connected to WS!", {
position: "bottom-right"
})
setStaff(data.staff)
sendMessage(JSON.stringify({
type: "room",
room: roomName
}))
}
if (data.type == "notification") {
toast(data.message);
}
if (data.type == "history") {
setMessageHistory(data.messages);
}
if (data.type == "message") {
setMessageHistory(z => z.concat([data.message]));
}
if (data.type == "rooms") {
setRooms(data.rooms);
}
if (data.type == "people") {
setPeople(data.people);
}
if (data.type == "room") {
setRoomName(data.room);
}
if(data.type == "updateUser") {
//{type: 'updateUser', updateType: 'pfp', id: 'ieuavxi5nqbqpgow'}
console.log(data);
setPeople(z => {
return z.map(g => {
if(g.id == data.id) {
g = {...g,r:Math.random()}
if(data.updateType == "status") {
g = {...g,r:Math.random(),status:data.data}
}
}
return g;
});
})
}
}
}, [lastMessage]);
return (
<>
<div class="flex flex-row w-[100vw] h-[100vh] bg-slate-200 ">
<div class="flex-grow max-w-60 flex flex-col bg-slate-300 rounded-tr-xl h-full">
<Sidebar setCurrentPeopleView={setCurrentPeopleView} setUser={setUser} setSocketUrl={setSocketUrl} staff={staff} accountState={accountState} setAccountState={setAccountState} setRoomName={setRoomName} roomName={roomName} rooms={rooms} setSendRoomHidden={setSendRoomHidden}></Sidebar>
</div>
<div class="flex-grow flex flex-col">
<div class="h-full overflow-y-scroll">
<Chat messageHistory={messageHistory}></Chat>
</div>
<div class="w-full bg-slate-300 h-15">
<ChatBar sendMessage={sendMessage} readyState={readyState}></ChatBar>
</div>
</div>
<People currentPeopleView={currentPeopleView} setCurrentPeopleView={setCurrentPeopleView} user={user} setUser={setUser} people={people} roomName={roomName} staff={staff} messageHistory={messageHistory}></People>
</div>
<Toaster />
</>
)
}

View file

@ -0,0 +1,79 @@
import { useEffect, useRef, useState } from "preact/hooks";
import { apiDomain } from "../treaty";
import Person from "../miniComponents/Person";
function truncateText(text, length) {
let text2 = text;
if (length < text.length) {
text2 = text2.slice(0, length);
text2 += "..."
}
return text2;
}
export default function Chat({ messageHistory }) {
const chatRef = useRef<HTMLDivElement>();
function SpotifyWidget({ id, type }: { id: string, type: "episode" | "track" }) {
return <iframe style="border-radius:12px" src={"https://open.spotify.com/embed/" + type + "/" + id + "?utm_source=generator"} width="100%" height="152" frameBorder="0" allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture" loading="lazy"></iframe>
}
function ChatMessage({ username, id, chatMessage, time, images }) {
const [isClosed, setIsClosed] = useState<boolean>(true);
function onClick() {
setIsClosed(z => !z);
}
const tenorRegex = /(https:\/\/media\.tenor\.com\/[^\/]*\/[^.]*\.gif)/gm;
return <div>
<Person user={{username,id}} time={time}></Person>
<div class="m-2 text-black " onClick={onClick}>
{isClosed ? truncateText(chatMessage.replace(tenorRegex, ""), 200) : chatMessage}
</div>
<div>
<div class="m-2">
{(() => {
const reg = /.*https:\/\/open.spotify.com\/(track|episode)\/([0-9a-zA-Z]*).*/gm;
if(reg.test(chatMessage)) {
const type = chatMessage.replace(reg, "$1");
const id = chatMessage.replace(reg, "$2");
return <SpotifyWidget type={type} id={id}></SpotifyWidget>
}
})()}
</div>
</div>
<div class="m-2">
{images.map(z => {
return <img src={apiDomain+"/images/" + z} class="max-w-72"></img>
})}
{
[...chatMessage.matchAll(tenorRegex)].map(z => {
return <img src={z}></img>
})
}
</div>
</div>
}
useEffect(() => {
setTimeout(() => { // TODO: This is a ugly workaround, please fix ASAP!
if(chatRef.current.lastElementChild) {
chatRef.current.lastElementChild.scrollIntoView();
}
}, 200)
}, [messageHistory]);
return (
<>
<div class="" ref={chatRef}>
{
messageHistory.map(z => {
return <ChatMessage id={z.userID} images={z.images || []} time={z.time} username={z.username} chatMessage={z.content} ></ChatMessage>;
})
}
</div>
</>
)
}

View file

@ -0,0 +1,81 @@
import toast from "react-hot-toast";
import { ReadyState } from "react-use-websocket";
import { trty } from "../treaty";
import { useRef, useState } from "preact/hooks";
import Input from "../miniComponents/Input";
import GifPicker from "gif-picker-react";
export default function ChatBar({ sendMessage, readyState, }) {
const [images, setImages] = useState<string[]>([]);
const [qpickerOpen, setGpickerOpen] = useState<boolean>(false);
const chatInputRef = useRef<HTMLInputElement>();
async function uploadImage() {
let image_input = document.getElementById("bar_input") as HTMLInputElement
if (!image_input) {
image_input = document.createElement("input");
image_input.style.display = "none";
image_input.type = "file"
image_input.accept = "image/jpeg, image/png, image/jpg"
image_input.id = "bar_input"
document.body.appendChild(image_input)
image_input.addEventListener("change", () => {
toast.promise((async () => {
const req = await trty.api.profile.uploadImage.post({
file: image_input.files[0]
})
if (!(req.data instanceof Response)) {
const uploadId = req.data.uploadId;
setImages(z => z.concat([uploadId]))
}
})(), {
loading: "Uploading..",
success: "Uploaded!",
error: "Upload failed. :("
})
})
}
image_input.click()
}
return (
//class="h-6 bg-slate-500 text-white rounded-md p-4 w-full"
<>
<div class="m-2 flex flex-row items-center">
<Input extraClass="w-full h-full p-3" ref={chatInputRef} readOnly={readyState !== ReadyState.OPEN} placeholder={readyState == ReadyState.OPEN ? "write something here" : "not connected yet.."} onKeyUp={(e) => {
const me = e.target as HTMLInputElement;
if (e.code == "Enter" && me.value.trim()) {
sendMessage(JSON.stringify({
type: "message",
message: me.value.trim(),
images
}));
setImages([]);
me.value = "";
}
}}></Input>
<button onClick={uploadImage}>
<i class="fa-solid fa-upload text-3xl m-2 text-black "></i>
</button>
<button onClick={() => {
setGpickerOpen(z => !z);
}}>
<i class="fa-solid fa-explosion text-3xl m-2 text-black "></i>
</button>
<div class="absolute bottom-14 right-0">
{ qpickerOpen? <GifPicker onGifClick={(img) => {
console.log(img);
chatInputRef.current.value += img.url;
setGpickerOpen(false);
}} tenorApiKey="AIzaSyCDq45efUHa0ZBa9RV7gl3v8WqxyQlb4X0" clientKey={"trillium"}></GifPicker> : ""}
</div>
</div>
</>
)
}

View file

@ -0,0 +1,76 @@
import { useRef } from "preact/hooks";
import PeopleView from "./peopleViews/PeopleView";
import PersonView from "./peopleViews/PersonView";
import Button from "../miniComponents/Button";
export default function ({
roomName,
people,
staff,
messageHistory,
user,
setUser,
currentPeopleView,
setCurrentPeopleView
}) {
const peopleBarRef = useRef<HTMLDivElement>();
const peopleViews = {
people: <PeopleView setUser={setUser} staff={staff} setCurrentPeopleView={setCurrentPeopleView} roomName={roomName} people={people}></PeopleView>,
person: <PersonView user={user}></PersonView>
}
return <>
<div id="peopleBar" class="bg-slate-300 w-52 overflow-auto flex flex-col break-all" style="display:none;" ref={peopleBarRef}>
{
(() => {
const sidebar = peopleViews[currentPeopleView];
return sidebar;
})()
}
{
(() => {
const e = <Button extraClass="mt-auto" onClick={() => {
console.log(messageHistory)
const blob = new Blob([JSON.stringify(messageHistory)], {type: "application/json"})
const elem = window.document.createElement('a');
elem.href = window.URL.createObjectURL(blob);
elem.download = `${new Date().toUTCString()} trillium logs.json`;
document.body.appendChild(elem);
elem.click();
document.body.removeChild(elem);
}}>Export logs</Button>
if (!staff) return e;
if (currentPeopleView == "person") {
return <Button extraClass="mt-auto" onClick={
() => {
setCurrentPeopleView("people");
}
} >
View people
</Button>
} else if (currentPeopleView == "people") {
return e;
}
})()
}
</div>
<button class="absolute top-4 right-6" id="peopleBarButton" onClick={
(e) => {
const tt = (e.target as HTMLDivElement).parentElement;
if (peopleBarRef.current.style.display != "none") {
tt.className = tt.className.replace("right-64", 'right-6')
peopleBarRef.current.style.display = "none"
} else {
tt.className = tt.className.replace('right-6', "right-64")
peopleBarRef.current.style.display = "flex";
}
}
}>
<i class="fa-solid fa-ellipsis text-5xl text-black "></i>
</button></>;
}

View file

@ -0,0 +1,55 @@
import { useEffect, useState } from "preact/hooks"
import SidebarAccount from "./sidebarViews/SidebarAccount"
import SidebarRooms from "./sidebarViews/SidebarRooms";
import SidebarStaff from "./sidebarViews/SidebarStaff";
export default function Sidebar({ setCurrentPeopleView, setUser, setSocketUrl, staff, setRoomName, roomName, rooms, setSendRoomHidden, accountState, setAccountState }) {
const [currentSidebarView, setCurrentSidebarView] = useState<keyof typeof sidebarViews>(localStorage.sidebarView || "account");
const sidebarViews = {
account: <SidebarAccount setSocketUrl={setSocketUrl} accountState={accountState} setAccountState={setAccountState} currentView={currentSidebarView}></SidebarAccount>,
rooms: <SidebarRooms currentView={currentSidebarView} setSendRoomHidden={setSendRoomHidden} setRoomName={setRoomName} roomName={roomName} rooms={rooms}></SidebarRooms>,
staff: <SidebarStaff setCurrentPeopleView={setCurrentPeopleView} setUser={setUser} currentView={currentSidebarView}></SidebarStaff>
}
useEffect(() => {
localStorage.sidebarView = currentSidebarView;
}, [currentSidebarView])
return (
<>
<div class="self-top self-center my-5 w-[70%]">
<img src="logo.svg"></img>
<h1 class='text-center text-5xl font-bold'>trillium</h1>
</div>
<div class=" text-white h-full overflow-y-auto">
{
(() => {
const sidebar = sidebarViews[currentSidebarView];
return sidebar;
})()
}
</div>
<div class="mt-auto">
<div class="p-2 rounded-lg m-2 bg-slate-500 text-white text-center">
<button class={currentSidebarView == "account" ? "text-green-300" : ""} onClick={() => {
setCurrentSidebarView("account")
}}>
<i class="fa-solid fa-user text-4xl mr-2 hover:underline"></i>
</button>
<button class={currentSidebarView == "rooms" ? "text-green-300" : ""} onClick={() => {
setCurrentSidebarView("rooms")
}}>
<i class="fa-solid fa-users-rays text-4xl mr-2 hover:underline"></i>
</button>
{staff ?
<button class={currentSidebarView == "staff" ? "text-green-300" : ""} onClick={() => {
setCurrentSidebarView("staff")
}}>
<i class="fa-solid fa-user-shield text-4xl mr-2 hover:underline"></i>
</button> : ""}
</div>
</div>
</>
)
}

View file

@ -0,0 +1,21 @@
import toast from "react-hot-toast";
import Person from "../../miniComponents/Person";
import { useEffect } from "preact/hooks";
export default function ({ people, staff, roomName, setCurrentPeopleView, setUser }) {
return <>
<h1 class="text-center text-black"><kbd>{roomName}</kbd></h1>
<h1 class="text-center text-black">{people.length} pe{people.length == 1 ? "rson" : "ople"}</h1>
{people.map(z => {
return <Person user={z} viewStatus={true} onClick={async () => {
if (staff) {
setUser(z);
setCurrentPeopleView("person")
} else {
await navigator.clipboard.writeText(z.id);
toast("Copied ID.")
}
}}></Person>
})}
</>
}

View file

@ -0,0 +1,119 @@
import { useEffect, useRef, useState } from "preact/hooks";
import toast from "react-hot-toast";
import Button from "../../miniComponents/Button";
import Input from "../../miniComponents/Input";
import Person from "../../miniComponents/Person";
import { trty } from "../../treaty";
export default function ({ user }) {
const [accountState, setAccountState] = useState<undefined | {
isOnline: boolean,
rooms: string[],
name: string
}>(undefined)
const banDialogRef = useRef<HTMLDialogElement>()
const banPermanentRef = useRef<HTMLInputElement>()
const banReasonRef = useRef<HTMLInputElement>()
const banTimeRef = useRef<HTMLInputElement>()
const muteDialogRef = useRef<HTMLDialogElement>()
const mutePermanentRef = useRef<HTMLInputElement>()
const muteReasonRef = useRef<HTMLInputElement>()
const muteTimeRef = useRef<HTMLInputElement>()
useEffect(() => {
async function asyncFunc() {
const data = (await trty.api.staff.isUserOnline.post({
userId: user.id
})).data;
if (data) {
setAccountState(data as {
isOnline: boolean,
rooms: string[],
name: string
})
} else {
setAccountState(undefined)
}
}
asyncFunc()
}, [user])
return <>
<dialog class="bg-slate-400 p-5 rounded-xl" ref={banDialogRef}>
<div class="flex flex-col items-center" >
<Input placeholder="Reason" extraClass="mb-1" ref={banReasonRef}></Input>
<Input placeholder="Time (in MS)" extraClass="mb-1" type="number" ref={banTimeRef}></Input>
<div class="mb-2">
<Input type="checkbox" ref={banPermanentRef}></Input> is Permanent?
</div>
<Button onClick={() => {
banDialogRef.current.close();
const time = banTimeRef.current.value;
const reason = banReasonRef.current.value;
const isPermanent = banPermanentRef.current.checked;
toast.promise((async () => {
await trty.api.staff.ban.post({
id: user.id,
reason: reason,
ms: +time,
isPermanent: isPermanent,
})
})(), {
loading: "Banning..",
success: "Banned!",
error: "Failed to ban."
})
}}>Ban them</Button>
</div>
</dialog>
<dialog class="bg-slate-400 p-5 rounded-xl" ref={muteDialogRef}>
<div class="flex flex-col items-center" >
<Input placeholder="Reason" extraClass="mb-1" ref={muteReasonRef}></Input>
<Input placeholder="Time (in MS)" extraClass="mb-1" type="number" ref={muteTimeRef}></Input>
<div class="mb-2">
<Input type="checkbox" ref={mutePermanentRef}></Input> is Permanent?
</div>
<Button onClick={() => {
muteDialogRef.current.close();
const time = muteTimeRef.current.value;
const reason = muteReasonRef.current.value;
const isPermanent = mutePermanentRef.current.checked;
toast.promise((async () => {
await trty.api.staff.mute.post({
id: user.id,
reason: reason,
ms: +time,
isPermanent: isPermanent,
})
})(), {
loading: "Muting..",
success: "Muted!",
error: "Failed to mute."
})
}}>Mute them</Button>
</div>
</dialog>
{accountState ? <>
<Person user={{id: user.id,username:accountState.name}}></Person>
<p class="text-center text-black">This user is currently <strong>{accountState.isOnline ? "online" : "offline"}.</strong></p>
{accountState.isOnline ? <p class="text-center text-black">They are in the room/s <strong>{accountState.rooms.join(", ")}</strong></p> : ""}
<Button extraClass="font-bold" onClick={async () => {
await navigator.clipboard.writeText(user.id)
toast("Copied ID.")
}}>{user.id}<br></br>(click to copy)</Button>
<Button onClick={() => banDialogRef.current.showModal()}>Ban</Button>
<Button onClick={() => muteDialogRef.current.showModal()}>Mute</Button>
</> : "Loading.."}
</>
}

View file

@ -0,0 +1,188 @@
import { useEffect, useRef, useState } from "preact/hooks";
import toast from 'react-hot-toast';
import { useGoogleReCaptcha } from "react-google-recaptcha-v3";
import Button from "../../miniComponents/Button";
import Input from "../../miniComponents/Input";
import { trty } from "../../treaty";
export default function SidebarAccount({ setSocketUrl, currentView, accountState, setAccountState }) {
const rerun = useState<boolean>(false);
useEffect(() => {
async function asyncFunc() {
const data = (await trty.api.auth.userInfo.get()).data;
if (data) {
setAccountState(data as { username: string })
} else {
setAccountState(undefined)
}
}
asyncFunc()
}, [rerun])
function AccountSignedIn() {
const statusRef = useRef<HTMLInputElement>();
async function handleLogout() {
await trty.api.auth.logout.get();
rerun[1](a => !a)
}
async function uploadImage() {
let image_input = document.getElementById("profile_picture_input") as HTMLInputElement
if (!image_input) {
image_input = document.createElement("input");
image_input.style.display = "none";
image_input.type = "file"
image_input.accept = "image/jpeg, image/png, image/jpg"
image_input.id = "profile_picture_input"
document.body.appendChild(image_input)
image_input.addEventListener("change", () => {
toast.promise((async () => {
await trty.api.profile.setProfilePicture.post({
file: image_input.files[0]
})
})(), {
loading: "Uploading..",
success: "Uploaded!",
error: "Upload failed. :("
})
})
}
image_input.click()
}
return (
<>
<div class="flex flex-col">
<h1 class="text-center text-xl text-white ">Signed in as <strong>{accountState.username}</strong></h1>
<Button onClick={handleLogout}>Logout</Button>
<Button onClick={uploadImage}>
<div class="flex items-center text-center">
<i class="fa-solid fa-image text-3xl text-slate-500 mr-2"></i>Set a profile picture
</div>
</Button>
<Input placeholder="Set a status!" ref={statusRef}></Input>
<Button onClick={() => {
toast.promise((async () => {
if(statusRef.current.value.trim()) {
await trty.api.profile.setStatus.post({
status: statusRef.current.value
});
}
})(), {
success: "Status set!",
loading: "Setting status..",
error: "Failed to set status."
})
}}>Set status</Button>
</div>
</>
)
}
function AccountSignedOut() {
const usernameRef = useRef<HTMLInputElement>();
const passwordRef = useRef<HTMLInputElement>();
const { executeRecaptcha } = useGoogleReCaptcha();
async function handleSignIn() {
const username = usernameRef.current.value.trim();
const password = passwordRef.current.value.trim();
if (!username || !password) {
toast.error("Missing username or password!", {
position: "bottom-right",
})
return;
}
const loadingToast = toast.loading("Logging in..", {
position: "bottom-right",
});
const token = await executeRecaptcha("login");
const request = await trty.api.auth.login.post({
password: password,
username: username,
recaptcha: token
})
if (request.status != 200) {
toast.error(request.error.value as unknown as string, {
position: "bottom-right",
});
toast.remove(loadingToast);
return;
}
toast.success(`You've logged into ${username}!`, {
position: "bottom-right",
})
toast.remove(loadingToast);
setSocketUrl(z => z.replace(/\?.*$/gm, "") + "?a=" + Math.random())
rerun[1](a => !a)
}
async function handleRegister() {
const username = usernameRef.current.value.trim();
const password = passwordRef.current.value.trim();
if (!username || !password) {
toast.error("Missing username or password!", {
position: "bottom-right",
})
return;
}
const loadingToast = toast.loading("Registering..", {
position: "bottom-right",
});
const token = await executeRecaptcha("register");
const request = await trty.api.auth.register.post({
password: password,
username: username,
recaptcha: token
})
if (request.status != 200) {
toast.error(request.error.value as unknown as string, {
position: "bottom-right",
});
toast.remove(loadingToast);
return;
}
toast.success(`Your account ${username} has been registered!`, {
position: "bottom-right",
})
toast.remove(loadingToast);
}
return (
<>
<div class="flex flex-col">
<Input placeholder="Username" extraClass="mb-1" type="username" ref={usernameRef}></Input>
<Input placeholder="Password" extraClass="mb-1" type="password" ref={passwordRef}></Input>
<Button onClick={handleSignIn}>Sign In</Button>
<Button onClick={handleRegister}>Register</Button>
<div class="text-sm m-2">
This site is protected by reCAPTCHA and the Google <a class="text-gray-400" href="https://policies.google.com/privacy">Privacy Policy</a> and <a href="https://policies.google.com/terms" class="text-gray-400">Terms of Service</a> apply.
</div>
</div>
</>
)
}
return (
<>
<h1 class="text-center text-3xl text-white ">Account</h1>
{
(() => {
if (!accountState) {
return <AccountSignedOut></AccountSignedOut>
} else {
return <AccountSignedIn></AccountSignedIn>
}
})()
}
</>
)
}

View file

@ -0,0 +1,60 @@
import { useRef } from "preact/hooks";
import Button from "../../miniComponents/Button";
import Input from "../../miniComponents/Input";
export default function SidebarRooms({ setSendRoomHidden, setRoomName, roomName, currentView, rooms }) {
const dialogRef = useRef<HTMLDialogElement>();
const roomInputRef = useRef<HTMLInputElement>();
const roomIsHiddenRef = useRef<HTMLInputElement>();
function createRoomDialog() {
dialogRef.current.showModal();
}
function createRoom() {
const input = roomInputRef.current.value.trim();
if (input) {
setSendRoomHidden(roomIsHiddenRef.current.checked);
localStorage.lastRoom = input;
setRoomName(input);
dialogRef.current.close();
}
}
return (
<>
<dialog ref={dialogRef} class="bg-slate-400 p-5 rounded-xl">
<div class="flex flex-col">
<Input ref={roomInputRef} placeholder="My Awsome Room"></Input>
<div class="text-center text-white my-2">
<Input type="checkbox" extraClasses="mr-2" ref={roomIsHiddenRef}></Input>
Is room hidden?
</div>
<Button onClick={createRoom}>Create room</Button>
</div>
</dialog>
<h1 class="text-center text-3xl text-white ">Rooms</h1>
<div class="flex flex-col">
{
rooms ?
rooms.map(z => {
return <Button onClick={
() => {
setSendRoomHidden(false);
setRoomName(z.name)
}
} extraClass={roomName == z.name ? "bg-green-200" : "bg-slate-400"}>
<div class="text-2xl">{z.name}</div>
<div>{z.count} pe{z.count == 1 ? "rson" : "ople"}</div>
</Button>
}) : <h1 class="text-2xl text-black text-center">Loading..</h1>
}
<Button onClick={createRoomDialog}>
Create your own room
</Button>
</div>
</>
)
}

View file

@ -0,0 +1,63 @@
import { useRef, useState } from "preact/hooks";
import toast from 'react-hot-toast';
import Button from "../../miniComponents/Button";
import Input from "../../miniComponents/Input";
import { trty } from "../../treaty";
export default function SidebarStaff({ setCurrentPeopleView, currentView, setUser }) {
const userIdRef = useRef<HTMLInputElement>();
const [punishments, setPunishments] = useState<undefined | any[]>();
return (
<>
<h1 class="text-center text-3xl text-white">Staff</h1>
<div class="border-b-4">
<Input placeholder="user ID" extraClass="m-0" ref={userIdRef}></Input>
<Button extraClass="m-0 py-2 my-2 w-full" onClick={async () => {
const req = await trty.api.staff.punishments.post({
userId: userIdRef.current.value
})
if (req.data) setPunishments(req.data as any);
}}>See punishments</Button>
<Button extraClass="m-0 py-2 mb-2 w-full" onClick={() => {
const bar = document.getElementById("peopleBar");
const barButton = document.getElementById("peopleBarButton");
if (bar.style.display !== "flex") {
bar.style.display = "flex";
barButton.className = barButton.className.replace('right-6', "right-64")
}
setCurrentPeopleView("person");
setUser({ id: userIdRef.current.value });
}}>User actions</Button>
</div>
{punishments === undefined ? "No user searched." : punishments.length == 0 ? "This user has no punishments!" : punishments.map((z, i) => {
return <div class={(i == 0 ? "" : "border-t-2") + " text-black"}>
<Button extraClass="p-2 m-1" onClick={() => {
toast.promise((async () => {
await trty.api.staff.invalidatePunishment.post({
punishmentId: z.id
})
})(), {
loading: "Invalidating punishment..",
success: "Invalidated.",
error: "Failed to invalidate!"
})
}}>Remove punishment</Button>
<div><i class={"text-white fa-solid fa-" + (z.type == "ban" ? "hammer-crash" : "comment-slash")}></i> {z.type.toUpperCase()}</div>
<div>ID: <strong>{z.id}</strong></div>
<div>Reason: <strong>{z.reason}</strong></div>
<div>Banned by: <strong>{z.staffId}</strong></div>
<div>Time: <strong>{z.time}ms</strong></div>
<div>Banned at: <strong>{z.at.toString()}</strong></div>
</div>
})}
</>
)
}

8
web/src/index.css Normal file
View file

@ -0,0 +1,8 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
* {
scrollbar-width: thin;
}

35
web/src/index.tsx Normal file
View file

@ -0,0 +1,35 @@
/*
What if I never make it back from this damn panic attack?
GTB sliding in a Hellcat bumping From First to Last
When I die they're gonna make a park bench saying "This where he sat"
Me and Yung Sherman going rehab, this shit is very sad
Me and Yung Sherm in Venice Beach, man, this shit is very rad
Me and Yung Sherman at the gym working out and getting tanned
I never will see you again and I hope you understand
I'm crashing down some like a wave over castles made of sand
*/
import { render } from 'preact';
import { GoogleReCaptchaProvider } from 'react-google-recaptcha-v3';
export function combineTailwind(first, second) {
if(!second) return first;
const secondSplit = second.split(" ")
let classes = [];
const secondIds = secondSplit.map(z => z.split("-")[0]);
for(const defaultClass of first.split(" ")) {
if(!secondIds.includes(defaultClass.split("-")[0])) {
classes.push(defaultClass);
}
}
return classes.concat(secondSplit).join(" ")
}
import './index.css'
import App from './components/App';
render(
<GoogleReCaptchaProvider reCaptchaKey='6Lc8vOApAAAAAHAZlkLXSGe4Qe2J3gLMqRtodETV'><App /></GoogleReCaptchaProvider>, document.getElementById('app'));

View file

@ -0,0 +1,6 @@
import { combineTailwind } from "..";
export default function Button({onClick = undefined, extraClass = "", children}) {
const combined = combineTailwind("bg-slate-200 text-black m-2 p-4 rounded-md", extraClass);
return <button class={combined} onClick={onClick}>{children}</button>;
}

View file

@ -0,0 +1,7 @@
import { ForwardedRef, forwardRef } from "preact/compat";
import { combineTailwind } from "..";
export default forwardRef(function Input(props: any, ref: ForwardedRef<HTMLInputElement>) {
const cssClass = combineTailwind("text-white bg-slate-500 rounded-md mx-2 mb-1 pl-2", props.extraClass)
return <input class={cssClass} placeholder={props.placeholder} ref={ref} type={props.type} onKeyUp={props.onKeyUp}></input>;
})

View file

@ -0,0 +1,34 @@
import toast from "react-hot-toast";
import { apiDomain } from "../treaty";
export async function copyId(id) {
await navigator.clipboard.writeText(id);
toast("Copied ID.")
}
export default function Person({ onClick = undefined, user, time = undefined, viewStatus = false }) {
if (!onClick) onClick = _ => copyId(user.id);
let hasEmoji = "";
let status = "";
if (user.status && viewStatus) {
status = user.status;
const segmented = user.status.split(" ");
const isFirstEmoji = /\p{Emoji_Presentation}/gu.test(segmented[0])
if (isFirstEmoji) {
hasEmoji = segmented[0]
status = segmented.slice(1).join(" ");
}
}
return <button class="flex text-black flex-col m-2" onClick={onClick}>
<div class="flex-row flex items-center">
<img src={apiDomain + "/pfps/" + user.id + "?r=" + user.r} class="w-12 h-12 rounded-full" onError={(e) => {
(e.target as HTMLImageElement).src = "https://placehold.co/400";
}}></img>
<div class="text-2xl ml-2">{user.username}</div>
{time ? <span class="ml-2 text-slate-500 text-sm">{new Date(time).toUTCString()}</span> : ""}
</div>
{user.status && viewStatus ?
<div class="text-sm text-left">{hasEmoji ? <span class="text-xl">{hasEmoji} </span> : ""}{status}</div> : ""}
</button>
}

15
web/src/treaty.tsx Normal file
View file

@ -0,0 +1,15 @@
import { treaty } from '@elysiajs/eden'
import type { App } from '../../server/src/index'
export let apiDomain = 'https://api.chat.sad.ovh';
if(location.hostname == "127.0.0.1" || location.hostname == "localhost") {
apiDomain = "http://localhost:8001"
}
//@ts-ignore
export const trty = treaty<App>(apiDomain, {
fetch: {
credentials: "include"
}
});

11
web/tailwind.config.js Normal file
View file

@ -0,0 +1,11 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
darkMode: "class",
plugins: [],
}

20
web/tsconfig.json Normal file
View file

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"noEmit": true,
"allowJs": true,
"checkJs": true,
"noErrorTruncation": true,
/* Preact Config */
"jsx": "react-jsx",
"jsxImportSource": "preact",
"skipLibCheck": true,
"paths": {
"react": ["./node_modules/preact/compat/"],
"react-dom": ["./node_modules/preact/compat/"]
}
},
"include": ["node_modules/vite/client.d.ts", "**/*"]
}

7
web/vite.config.ts Normal file
View file

@ -0,0 +1,7 @@
import { defineConfig } from "vite";
import preact from "@preact/preset-vite";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [preact()],
});