initial commit
This commit is contained in:
commit
6f5a39c212
47 changed files with 3601 additions and 0 deletions
132
web/src/components/App.tsx
Normal file
132
web/src/components/App.tsx
Normal 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 />
|
||||
</>
|
||||
)
|
||||
}
|
||||
79
web/src/components/Chat.tsx
Normal file
79
web/src/components/Chat.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
81
web/src/components/ChatBar.tsx
Normal file
81
web/src/components/ChatBar.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
76
web/src/components/People.tsx
Normal file
76
web/src/components/People.tsx
Normal 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></>;
|
||||
}
|
||||
55
web/src/components/Sidebar.tsx
Normal file
55
web/src/components/Sidebar.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
21
web/src/components/peopleViews/PeopleView.tsx
Normal file
21
web/src/components/peopleViews/PeopleView.tsx
Normal 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>
|
||||
})}
|
||||
</>
|
||||
}
|
||||
119
web/src/components/peopleViews/PersonView.tsx
Normal file
119
web/src/components/peopleViews/PersonView.tsx
Normal 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.."}
|
||||
</>
|
||||
}
|
||||
188
web/src/components/sidebarViews/SidebarAccount.tsx
Normal file
188
web/src/components/sidebarViews/SidebarAccount.tsx
Normal 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>
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
</>
|
||||
)
|
||||
}
|
||||
60
web/src/components/sidebarViews/SidebarRooms.tsx
Normal file
60
web/src/components/sidebarViews/SidebarRooms.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
63
web/src/components/sidebarViews/SidebarStaff.tsx
Normal file
63
web/src/components/sidebarViews/SidebarStaff.tsx
Normal 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
8
web/src/index.css
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
35
web/src/index.tsx
Normal file
35
web/src/index.tsx
Normal 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'));
|
||||
6
web/src/miniComponents/Button.tsx
Normal file
6
web/src/miniComponents/Button.tsx
Normal 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>;
|
||||
}
|
||||
7
web/src/miniComponents/Input.tsx
Normal file
7
web/src/miniComponents/Input.tsx
Normal 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>;
|
||||
})
|
||||
34
web/src/miniComponents/Person.tsx
Normal file
34
web/src/miniComponents/Person.tsx
Normal 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
15
web/src/treaty.tsx
Normal 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"
|
||||
}
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue