Chatserver

This commit is contained in:
MeowcaTheoRange 2024-05-09 14:22:55 -05:00
parent 75f7a7ed61
commit 3cca3b269e
9 changed files with 1001 additions and 22 deletions

123
package-lock.json generated
View file

@ -9,6 +9,7 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@vercel/postgres-kysely": "^0.8.0", "@vercel/postgres-kysely": "^0.8.0",
"cookies-next": "^4.1.1",
"formik": "^2.4.5", "formik": "^2.4.5",
"kysely": "^0.27.3", "kysely": "^0.27.3",
"nanoid": "^5.0.7", "nanoid": "^5.0.7",
@ -18,6 +19,7 @@
"react-markdown": "^9.0.1", "react-markdown": "^9.0.1",
"rehype-raw": "^7.0.0", "rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.0", "remark-gfm": "^4.0.0",
"socket.io-client": "^4.7.5",
"use-sound": "^4.0.1" "use-sound": "^4.0.1"
}, },
"devDependencies": { "devDependencies": {
@ -175,6 +177,11 @@
"node": ">= 10" "node": ">= 10"
} }
}, },
"node_modules/@socket.io/component-emitter": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="
},
"node_modules/@swc/helpers": { "node_modules/@swc/helpers": {
"version": "0.5.2", "version": "0.5.2",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.2.tgz", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.2.tgz",
@ -183,6 +190,11 @@
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@types/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="
},
"node_modules/@types/debug": { "node_modules/@types/debug": {
"version": "4.1.12", "version": "4.1.12",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
@ -423,6 +435,29 @@
"url": "https://github.com/sponsors/wooorm" "url": "https://github.com/sponsors/wooorm"
} }
}, },
"node_modules/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookies-next": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/cookies-next/-/cookies-next-4.1.1.tgz",
"integrity": "sha512-20QaN0iQSz87Os0BhNg9M71eM++gylT3N5szTlhq2rK6QvXn1FYGPB4eAgU4qFTunbQKhD35zfQ95ZWgzUy3Cg==",
"dependencies": {
"@types/cookie": "^0.6.0",
"@types/node": "^16.10.2",
"cookie": "^0.6.0"
}
},
"node_modules/cookies-next/node_modules/@types/node": {
"version": "16.18.97",
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.97.tgz",
"integrity": "sha512-4muilE1Lbfn57unR+/nT9AFjWk0MtWi5muwCEJqnOvfRQDbSfLCUdN7vCIg8TYuaANfhLOV85ve+FNpiUsbSRg=="
},
"node_modules/csstype": { "node_modules/csstype": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
@ -484,6 +519,60 @@
"url": "https://github.com/sponsors/wooorm" "url": "https://github.com/sponsors/wooorm"
} }
}, },
"node_modules/engine.io-client": {
"version": "6.5.3",
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.5.3.tgz",
"integrity": "sha512-9Z0qLB0NIisTRt1DZ/8U2k12RJn8yls/nXMZLn+/N8hANT3TcYjKFKcwbw5zFQiN4NTde3TSY9zb79e1ij6j9Q==",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.11.0",
"xmlhttprequest-ssl": "~2.0.0"
}
},
"node_modules/engine.io-client/node_modules/utf-8-validate": {
"version": "5.0.10",
"resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz",
"integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==",
"hasInstallScript": true,
"optional": true,
"peer": true,
"dependencies": {
"node-gyp-build": "^4.3.0"
},
"engines": {
"node": ">=6.14.2"
}
},
"node_modules/engine.io-client/node_modules/ws": {
"version": "8.11.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.11.0.tgz",
"integrity": "sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": "^5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/engine.io-parser": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.2.tgz",
"integrity": "sha512-RcyUFKA93/CXH20l4SoVvzZfrSDMOTUS3bWVpTt2FuFP+XYrL8i8oonHP7WInRyVHXh0n/ORtoeiE1os+8qkSw==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/entities": { "node_modules/entities": {
"version": "4.5.0", "version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
@ -1980,6 +2069,32 @@
"loose-envify": "^1.1.0" "loose-envify": "^1.1.0"
} }
}, },
"node_modules/socket.io-client": {
"version": "4.7.5",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.7.5.tgz",
"integrity": "sha512-sJ/tqHOCe7Z50JCBCXrsY3I2k03iOiUe+tj1OmKeD2lXPiGH/RUCdTZFoqVyN7l1MnpIzPrGtLcijffmeouNlQ==",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.2",
"engine.io-client": "~6.5.2",
"socket.io-parser": "~4.2.4"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io-parser": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz",
"integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.1"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz",
@ -2280,6 +2395,14 @@
} }
} }
}, },
"node_modules/xmlhttprequest-ssl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz",
"integrity": "sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/xtend": { "node_modules/xtend": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",

View file

@ -10,6 +10,7 @@
}, },
"dependencies": { "dependencies": {
"@vercel/postgres-kysely": "^0.8.0", "@vercel/postgres-kysely": "^0.8.0",
"cookies-next": "^4.1.1",
"formik": "^2.4.5", "formik": "^2.4.5",
"kysely": "^0.27.3", "kysely": "^0.27.3",
"nanoid": "^5.0.7", "nanoid": "^5.0.7",
@ -19,6 +20,7 @@
"react-markdown": "^9.0.1", "react-markdown": "^9.0.1",
"rehype-raw": "^7.0.0", "rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.0", "remark-gfm": "^4.0.0",
"socket.io-client": "^4.7.5",
"use-sound": "^4.0.1" "use-sound": "^4.0.1"
}, },
"devDependencies": { "devDependencies": {

392
src/app/chat/page.tsx Normal file
View file

@ -0,0 +1,392 @@
'use client';
import { ChatLayout } from "@/layout/ChatLayout/ChatLayout";
import { io } from "socket.io-client";
import { useEffect, useMemo, useRef, useState } from "react";
import { getCookie } from 'cookies-next';
import styles from "./styles.module.css";
import Markdown from "react-markdown";
import { Conditional } from "@/components/utility/Conditional";
type MessageType = {
channel: string,
message: string,
me: boolean,
user: string,
id: string,
timestamp: number
};
type Res = {type: string, spec: string, data: any};
export default function Home() {
const message_translation_tables = new Map([
["ERR_CHAT_JOIN", new Map([
["main", "Error when joining chat"],
["JOINED", "You've already joined Chat"],
["MALFORMED_TOKEN", "You have a malformed token"],
["INVALID_TOKEN", "Your token is invalid or expired"],
["INVALID_USER", "Your token has no user or your user is banned (shame on you)"],
])],
["ERR_CHAT_WHOISTHIS", new Map([
["main", "Error when fetching user"],
["NO_USER", "Unauthenticated request"],
["MALFORMED_USER", "You have a malformed user ID"],
["INVALID_USER", "No user with this ID"],
])],
["ERR_CHAT_CHGCHANNEL", new Map([
["main", "Error when changing channel"],
["NO_USER", "Unauthenticated request"],
["MALFORMED_CHANNEL", "You have a malformed channel selection; try again"],
["INVALID_CHANNEL", "No channel with this name"],
["CHANNEL_UNREADABLE", "You are not authorized to read this channel"]
])],
["ERR_CHAT_MESSAGE", new Map([
["main", "Error when sending message"],
["NO_USER", "Unauthenticated request"],
["MALFORMED_MESSAGE", "You have a malformed message; try again"],
["DATABASE_ERROR", "The database encountered an error (contact me@abtmtr.link)"],
["CHANNEL_UNWRITABLE", "You are not authorized to write to this channel"],
["RATE_LIMIT", "You are being rate-limited"],
["RATE_LIMIT_BAN", "You have been disconnected due to exceeding the rate-limit"]
])],
["ERR_CHAT_RMMESSAGE", new Map([
["main", "Error when removing message"],
["NO_USER", "Unauthenticated request"],
["MALFORMED_MESSAGEID", "You have a malformed message selection; try again"],
["NULL_MESSAGE", "No message with this ID"],
["NUH_UH", "You are not authorized to remove this message"],
["DATABASE_ERROR", "The database encountered an error (contact me@abtmtr.link)"],
["RATE_LIMIT", "You are being rate-limited"],
["RATE_LIMIT_BAN", "You have been disconnected due to exceeding the rate-limit"]
])],
["ERR_CHAT_USERS", new Map([
["main", "Error when fetching users"],
["NO_USER", "Unauthenticated request"]
])],
["ERR_CHAT_CHANNELS", new Map([
["main", "Error when fetching channels"],
["NO_USER", "Unauthenticated request"],
["DATABASE_ERROR", "The database encountered an error (contact me@abtmtr.link)"]
])],
["ERR_CHAT_MESSAGES", new Map([
["main", "Error when fetching messages"],
["NO_USER", "Unauthenticated request"],
["DATABASE_ERROR", "The database encountered an error (if this continues, contact me@abtmtr.link)"],
["CHANNEL_UNREADABLE", "You are not authorized to read this channel"]
])]
]);
const data_translation_tables:Map<string, {[key:string]:any}> = new Map([
["RATE_LIMIT", {
reset: (x:number) => `(Refreshes in ${x} seconds)`
}]
])
const socketLocation = "https://chatserver.abtmtr.link/";
const socketObject = useMemo(() => io(socketLocation), []);
const socket = socketObject;
const [ connected, setConnected ] = useState(false);
const [ error, _setError ] = useState("");
const [ whoami, _setWhoAmI ] = useState<{[key:string]:any}>({});
const whoamiRef = useRef(whoami);
const setWhoAmI = (x:any) => {whoamiRef.current = x;_setWhoAmI(x);};
const [ channel, setChannel ] = useState("");
const [ channels, setChannels ] = useState<{
name: string,
action: () => void,
readable: boolean,
writable: boolean
}[]>([]);
const [ users, _setUsers ] = useState<{
id: string,
username: string,
instance: string,
admin: boolean
}[]>([]);
const usersRef = useRef(users);
const setUsers = (x:any) => {usersRef.current = x;_setUsers(x);};
const [ messages, _setMessages ] = useState<MessageType[]>([]);
const messagesRef = useRef(messages);
const setMessages = (x:any) => {messagesRef.current = x;_setMessages(x);};
const [ isLoading, setIsLoading ] = useState(false);
/* SECT: LHUA; Lord help us all these ones */
const [ textBox, setTextBox ] = useState<React.ReactNode>(null);
let Message = useRef<(message:MessageType) => React.ReactNode>(() => <></>);
const [, forceUpdate] = useState({});
const externCachedUsers = useRef(new Map());
/* END_SECT LHUA; */
const chatInput = useRef<HTMLTextAreaElement>(null);
const chatPaneRef = useRef<HTMLDivElement>(null);
function unsetError() {
_setError("");
}
function setError(type:string, spec:string, data?:any) {
let errorTable = message_translation_tables.get(type);
let dataTable = data_translation_tables.get(spec);
let errorTableType;
let errorTableSpec;
if (errorTable == null) {
errorTableType = type;
errorTableSpec = spec;
} else {
errorTableType = errorTable.get("main");
if (errorTableType == null)
errorTableType = type;
errorTableSpec = errorTable.get(spec);
if (errorTableSpec == null)
errorTableSpec = spec;
}
let dataTableSpec;
if (dataTable != null && data != null) {
dataTableSpec = Object.entries(data).reduce((prev, [k,v]) => prev + " " + dataTable[k](v), "");
}
if (dataTableSpec == null)
_setError(`${errorTableType}: ${errorTableSpec}`);
else
_setError(`${errorTableType}: ${errorTableSpec} ${dataTableSpec}`)
}
useEffect(() => {
/* SECT: BSN; Because "soft navigation" */
// @ts-ignore
if (window.socket && window.socket !== socket)
// @ts-ignore
window.socket.disconnect();
// @ts-ignore
window.socket = socket;
/* END_SECT BSN; */
function onConnect() {
setConnected(true);
const token = getCookie('token');
if (token != null)
socket.emit('SIG_CHAT_JOIN', { token }, joinChatRes);
setTextBox(<div className={styles.textBox}>
<textarea rows={2} className={styles.textBoxInput} ref={chatInput}></textarea>
<button className={styles.textBoxButton} onClick={() => socket.emit('SIG_CHAT_MESSAGE', { message: chatInput.current?.value }, messageChatRes)}>Send</button>
</div>)
Message.current = (message:MessageType) => {
let user;
user = usersRef.current.find(x => x.id == message.user);
if (user == null)
user = externCachedUsers.current.get(message.user);
if (user == null) {
new Promise((res, rej) => {
socket.emit('SIG_CHAT_WHOISTHIS', { id: message.user }, ({ type, spec, data }:Res) => {
if (type.includes("ERR"))
rej(`${type} - ${spec}`);
else {
externCachedUsers.current.set(data.id, data);
res("");
forceUpdate({});
}
});
});
}
const canDelete = whoamiRef.current.admin || whoamiRef.current.id == message.user;
const timeSent = new Date(message.timestamp * 1000).toLocaleTimeString();
return <div className={styles.Message} key={message.id}>
<h3 className={styles.MessageHeader}>
<a href={`/jams/user/${user?.id}`} target="_blank">{user?.username}@{user?.instance}</a>
{" "}
<Conditional condition={canDelete}
truthy={<button onClick={() => socket.emit('SIG_CHAT_RMMESSAGE', { messageId: message.id }, rmmessageChatRes)} className={styles.deleteButton}>
<small>{timeSent}</small>
</button>}
falsy={<small>{timeSent}</small>}
/>
</h3>
<Markdown>{message.message}</Markdown>
</div>
};
}
function onDisconnect() {
setConnected(false);
}
if (socket.connected && !connected) {
onConnect();
}
socket.on("connect", onConnect);
socket.on("disconnect", onDisconnect);
window.addEventListener("navigate", socket.disconnect);
return () => {
socket.off("connect", onConnect);
socket.off("disconnect", onDisconnect);
};
}, []);
// user stuff
function addUser(userdata:{
id: string,
username: string,
instance: string,
admin: boolean
}) {
setUsers(usersRef.current.concat([ userdata ]));
}
function removeUser(userId:string) {
const index = usersRef.current.findIndex(x => x.id == userId);
if (index < 0) return;
setUsers(usersRef.current.toSpliced(index, 1));
}
function usersChatRes({ type, spec, data }:Res) {
unsetError();
if (type.includes("ERR")) {
setError(type, spec, data);
} else {
setUsers(data);
}
}
socket.off("RES_CHAT_JOIN");
socket.on("RES_CHAT_JOIN", ({ user }) => {
addUser(user);
});
socket.off("RES_CHAT_LEAVE");
socket.on("RES_CHAT_LEAVE", ({ userId }) => {
removeUser(userId);
});
const prevscrtop = useRef(0);
useEffect(() => {
if (
chatPaneRef.current != null
&& chatPaneRef.current.scrollTop >= prevscrtop.current
) {
chatPaneRef.current.scrollTop = chatPaneRef.current.scrollHeight;
prevscrtop.current = chatPaneRef.current.scrollTop;
}
}, [messages])
// message stuff
function addMessage(messagesdata:MessageType) {
setMessages(messagesRef.current.concat([ messagesdata ]));
}
function removeMessage(messageId:string) {
const index = messagesRef.current.findIndex(x => x.id == messageId);
if (index < 0) return;
setMessages(messagesRef.current.toSpliced(index, 1));
}
function messagesChatRes({ type, spec, data }:Res) {
unsetError();
if (type.includes("ERR")) {
setError(type, spec, data);
} else {
setMessages(data);
}
}
function messageChatRes({ type, spec, data }:Res) {
unsetError();
if (type.includes("ERR")) {
setError(type, spec, data);
} else {
addMessage(data);
}
if (chatInput.current != null)
chatInput.current.value = "";
}
function rmmessageChatRes({ type, spec, data }:Res) {
unsetError();
if (type.includes("ERR")) {
setError(type, spec, data);
} else {
removeMessage(data.messageId);
}
}
socket.off("RES_CHAT_MESSAGE");
socket.on("RES_CHAT_MESSAGE", ({ message }) => {
console.log(messages);
addMessage(message);
});
socket.off("RES_CHAT_RMMESSAGE");
socket.on("RES_CHAT_RMMESSAGE", ({ messageId }) => {
removeMessage(messageId);
});
function joinChatRes({ type, spec, data }:Res) {
unsetError();
if (type.includes("ERR")) {
setError(type, spec, data);
} else {
setWhoAmI(data);
socket.emit('SIG_CHAT_CHANNELS', {}, channelsChatRes);
}
}
function channelsChatRes({ type, spec, data }:Res) {
unsetError();
if (type.includes("ERR")) {
setError(type, spec, data);
} else {
setChannels(data.map((x:{[key:string]:any}) => {
x.action = () => {
socket.emit('SIG_CHAT_CHGCHANNEL', { channel: x.name }, chgchannelChatRes);
}
return x;
}));
}
}
function chgchannelChatRes({ type, spec, data }:Res) {
unsetError();
if (type.includes("ERR")) {
setError(type, spec, data);
} else {
prevscrtop.current = 0;
setChannel(data.channel);
socket.emit('SIG_CHAT_USERS', {}, usersChatRes);
socket.emit('SIG_CHAT_MESSAGES', {}, messagesChatRes);
}
}
return (
<ChatLayout
title="Chat"
subtitle={error}
props={{
channels,
users,
currentChannel: channel,
connected,
// @ts-ignore
whoami,
chatPaneRef
}}
footerChildren={textBox}
>
{messages.length > 0 && !isLoading ?
messages.map(Message.current)
: (whoami.admin || channels.find(x => x.name == channel)?.writable ?
<div style={{textAlign: "center"}}>
<h1>This channel is empty.</h1>
<p>Use the bar below to post something.</p>
</div>
: <></>)
}
</ChatLayout>
)
}

View file

@ -0,0 +1,33 @@
.textBox {
display: grid;
grid-template-columns: 1fr auto;
gap: 8px;
}
.textBoxInput {
background-color: var(--bg-2);
border: 1px solid var(--fg-2);
color: var(--fg-2);
padding: 4px;
resize: none;
}
.textBoxButton {
background-color: var(--bg-2);
border: 1px solid var(--fg-2);
color: var(--fg-2);
padding: 8px 16px;
}
.Message {
margin-top: 16px;
margin-bottom: 32px;
}
/* .MessageHeader {
font-size: 1.25em;
} */
.deleteButton:hover {
color: hsl(0, 20%, 75%);
}

View file

@ -0,0 +1,275 @@
@keyframes slidein {
0% {
position: relative;
top: 10px;
opacity: 0;
}
99% {
position: relative;
top: 0;
opacity: 1;
}
100% {
position: static;
top: unset;
}
}
@keyframes slidein_header {
0% {
position: relative;
left: -10px;
opacity: 0;
}
99% {
position: relative;
left: 0;
opacity: 1;
}
100% {
position: static;
top: unset;
}
}
.ChatLayout {
width: 100vw;
max-width: 100vw;
height: 100%;
max-height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
display: grid;
grid-template-columns: 256px 1fr;
}
.ChatLayout_Aside {
padding: 4px 16px;
background-color: var(--bg-1);
color: var(--fg-1);
border-right: 1px solid var(--neutral);
overflow-y: scroll;
}
.ChatLayout_Aside.Modifier_Open {}
.ChatLayout_Inner {
max-width: 100%;
max-height: 100%;
overflow: hidden;
display: grid;
grid-template-rows: 48px auto 64px;
}
.ChatLayout_Inner_Header {
padding: 6px 16px;
background-color: var(--bg-2);
color: var(--fg-2);
border-bottom: 1px solid var(--neutral);
overflow: hidden;
display: grid;
grid-template-columns: auto;
gap: 8px;
align-items: center;
}
.ChatLayout_Inner_Header>* {
animation-duration: 0.25s;
animation-name: slidein_header;
}
.ChatLayout_Inner_Header.Modifier_Back {
padding: 6px 8px;
grid-template-columns: 32px 1fr;
}
.ChatLayout_Inner_Header_Back_Button {
font-family: var(--font-MaterialSymbols);
font-size: 24px;
width: 32px;
height: 32px;
}
.ChatLayout_Inner_Header_Menu {
display: none;
}
.ChatLayout_Inner_Header_Title {
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
align-content: center;
width: 100%;
overflow: hidden;
}
.ChatLayout_Inner_Header_Title h1 {
display: inline-block;
margin: 0;
vertical-align: middle;
font-size: 1em;
text-wrap: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.ChatLayout_Inner_Header_Title span {
display: inline-block;
margin: 0;
vertical-align: middle;
font-size: 0.75em;
text-wrap: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.ChatLayout_Inner_Footer {
padding: 6px 16px;
background-color: var(--bg-1);
color: var(--fg-1);
border-top: 1px solid var(--neutral);
overflow: hidden;
display: grid;
grid-template-columns: auto;
gap: 8px;
align-items: center;
}
.ChatLayout_Inner_Main {
display: inline-block;
background-color: var(--bg-1);
color: var(--fg-1);
width: 100%;
overflow: hidden;
overflow-y: auto;
text-align: center;
position: relative;
}
.ChatLayout_Inner_Main_Content {
display: inline-block;
text-align: left;
overflow: hidden;
background-color: var(--bg-2);
color: var(--fg-2);
padding: 0 16px;
max-width: 50em;
width: 100%;
min-height: 100%;
margin-bottom: -4px;
/* border-left: 1px solid var(--neutral);
border-right: 1px solid var(--neutral); */
}
.ChatLayout_Inner_Main_Content>* {
animation-duration: 0.25s;
animation-name: slidein;
}
.ChatLayout_Inner_Main_Content img {
max-width: 100%;
}
@media screen and (max-width: 800px) {
.ChatLayout {
grid-template-columns: 1fr;
}
.ChatLayout_Aside {
position: absolute;
height: calc(100% - 48px);
width: 100%;
top: 48px;
z-index: 50;
left: -100%;
transition: left 0.125s;
}
.ChatLayout_Inner_Header_Menu {
display: inline-block;
}
.ChatLayout_Inner_Header {
padding: 6px 8px;
grid-template-columns: 32px auto;
}
.ChatLayout_Aside.Modifier_Open {
left: 0;
}
}
/* sidebar */
.Main_Image {
text-align: center;
margin: 16px 0;
}
.Main_Image img {
border-radius: 8px;
}
.Main_List {
display: flex;
flex-direction: column;
gap: 8px;
}
.Main_List>a {
text-decoration: none;
}
.Main_List_CurrentLink>div {
background-color: var(--bg-2);
}
.Main_List_Button {
color: var(--accent-color);
overflow: hidden;
text-overflow: ellipsis;
}
.Main_List_Button_Badge {
background-color: rgb(255, 64, 64);
color: white;
padding: 0 4px;
margin: 4px;
float: right;
border-radius: 4px;
pointer-events: none;
}
.Main_List_CurrentLink .Main_List_Button_Badge {
display: none;
}
.Main_List_Button_Slim {
padding: 2px 8px !important;
background-color: var(--bg-3);
color: var(--fg-2);
}
.Main_List_Button_Slim:hover {
background-color: var(--bg-2);
color: var(--fg-1);
}
/* .Main_List_Button_Slim:active {
scale: 1;
} */

View file

@ -0,0 +1,140 @@
'use client';
import { ConditionalNull } from "@/components/utility/Conditional";
import React, { useState } from "react";
import styles from "./ChatLayout.module.css";
import { useRouter } from "next/navigation";
import Link from "next/link";
export function ChatLayout<PropFormat>({
title,
subtitle,
backButton = false,
children,
footerChildren,
props,
}:{
title: string,
subtitle?: string,
backButton?: boolean,
children: React.ReactNode,
footerChildren: React.ReactNode,
props?:{
channels: {
name:string,
action: () => void,
readable: boolean,
writable: boolean
}[],
users: {
id: string,
username: string,
instance: string,
admin: boolean
}[],
currentChannel: string,
connected: boolean,
whoami?: {
id: string,
username: string,
instance: string
},
chatPaneRef: React.Ref<HTMLDivElement>
}
}) {
const [menuOpen, setMenuOpen] = useState<boolean>(false);
const router = useRouter();
return (
<div className={`${styles.ChatLayout}`}>
<aside className={`${styles.ChatLayout_Aside} ${menuOpen ? styles.Modifier_Open : ""}`}>
<h1 style={{marginBottom:0}}>Chat</h1>
<p>
<button
onClick={() => router.back()}
>
&lt; abtmtr.link
</button>
</p>
<hr />
<ConditionalNull condition={props?.connected == true && props?.channels != null}>
<p>Channels</p>
<div className={styles.Main_List}>
{props?.channels?.map((channel) => (<button
onClick={channel.action}
className={props.currentChannel === channel.name ? styles.Main_List_CurrentLink : ""}
title={`#${channel.name}`}
>
<div className={`fw ${styles.Main_List_Button}`}>
<span className="icon">tag</span>
<span>{channel.name}</span>
</div>
</button>))}
</div>
</ConditionalNull>
<ConditionalNull condition={
props?.connected == true && props?.channels != null
&& props?.connected == true && props?.users != null
}>
<hr />
</ConditionalNull>
<ConditionalNull condition={props?.connected == true && props?.users != null}>
<p>Users</p>
<div className={styles.Main_List}>
{props?.users.map((user) => (<Link
href={`/jams/user/${user.id}`}
className={props.whoami?.id === user.id ? styles.Main_List_CurrentLink : ""}
title={`${user.username}@${user.instance}`}
>
<div className={`fw ${styles.Main_List_Button}`}>
<span className="icon">person</span>
<span>{user.username}@{user.instance}</span>
</div>
</Link>))}
</div>
</ConditionalNull>
<ConditionalNull condition={
props?.connected == true && props?.users != null
}>
<hr />
</ConditionalNull>
<p>Chat API v2</p>
<p><i>made with hate for React<br />and love for irisnk.me</i></p>
</aside>
<div className={styles.ChatLayout_Inner}>
<div className={`${
styles.ChatLayout_Inner_Header
} ${
backButton ? styles.Modifier_Back : ""
}`}>
<ConditionalNull condition={!backButton}>
<div className={`${styles.ChatLayout_Inner_Header_Back} ${styles.ChatLayout_Inner_Header_Menu}`}>
<button className={styles.ChatLayout_Inner_Header_Back_Button} onClick={() => setMenuOpen(!menuOpen)}>
menu
</button>
</div>
</ConditionalNull>
<ConditionalNull condition={backButton}>
<div className={styles.ChatLayout_Inner_Header_Back}>
<button className={styles.ChatLayout_Inner_Header_Back_Button} onClick={() => router.back()}>
arrow_back
</button>
</div>
</ConditionalNull>
<div className={styles.ChatLayout_Inner_Header_Title}>
<h1>{title}</h1>
<span>{subtitle}</span>
</div>
</div>
<main className={styles.ChatLayout_Inner_Main} ref={props?.chatPaneRef}>
<div className={`${styles.ChatLayout_Inner_Main_Content} MainContent`}>
{children}
</div>
</main>
<div className={`${styles.ChatLayout_Inner_Footer}`}>
{footerChildren}
</div>
</div>
</div>
)
}

View file

@ -5,18 +5,22 @@ import { useState } from "react";
import styles from "./MainLayout.module.css"; import styles from "./MainLayout.module.css";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
export function Desktop({ export function Desktop<PropFormat>({
title, title,
subtitle, subtitle,
currentPage,
backButton = false, backButton = false,
children, children,
props,
sidebar sidebar
}:{ }:{
title: string, title: string,
subtitle?: string, subtitle?: string,
currentPage: string,
backButton?: boolean, backButton?: boolean,
children: React.ReactNode, children: React.ReactNode,
sidebar: React.ReactNode props?:PropFormat,
sidebar: (x:{currentPage:string, props?:PropFormat}) => React.ReactNode
}) { }) {
const [menuOpen, setMenuOpen] = useState<boolean>(false); const [menuOpen, setMenuOpen] = useState<boolean>(false);
const router = useRouter(); const router = useRouter();
@ -24,7 +28,7 @@ export function Desktop({
return ( return (
<div className={`${styles.MainLayout}`}> <div className={`${styles.MainLayout}`}>
<aside className={`${styles.MainLayout_Aside} ${menuOpen ? styles.Modifier_Open : ""}`}> <aside className={`${styles.MainLayout_Aside} ${menuOpen ? styles.Modifier_Open : ""}`}>
{sidebar} {sidebar({ currentPage, props })}
</aside> </aside>
<div className={styles.MainLayout_Inner}> <div className={styles.MainLayout_Inner}>
<div className={`${ <div className={`${

View file

@ -1,11 +1,12 @@
import { SidebarMain } from "./Sidebar/Main/Main"; import { SidebarMain } from "./Sidebar/Main/Main";
import { Desktop } from "./Desktop"; import { Desktop } from "./Desktop";
export function MainLayout({ export function MainLayout<PropFormat>({
title, title,
subtitle, subtitle,
backButton = false, backButton = false,
children, children,
props,
sidebar, sidebar,
currentPage currentPage
}:{ }:{
@ -13,8 +14,9 @@ export function MainLayout({
subtitle?: string, subtitle?: string,
backButton?: boolean, backButton?: boolean,
children: React.ReactNode, children: React.ReactNode,
sidebar?: React.ReactNode, props?:PropFormat,
sidebar?: (x:{ currentPage:string, props?:PropFormat }) => React.ReactNode,
currentPage: string currentPage: string
}) { }) {
return <Desktop title={title} subtitle={subtitle} backButton={backButton} sidebar={sidebar ?? <SidebarMain currentPage={currentPage} />}>{children}</Desktop> return <Desktop title={title} subtitle={subtitle} currentPage={currentPage} props={props} backButton={backButton} sidebar={sidebar ?? SidebarMain}>{children}</Desktop>
} }

View file

@ -3,7 +3,7 @@ import Link from "next/link";
import styles from "./Main.module.css"; import styles from "./Main.module.css";
// import useSound from 'use-sound'; // import useSound from 'use-sound';
export function SidebarMain({currentPage}:{currentPage:string}) { export function SidebarMain<PropFormat>({currentPage, props}:{currentPage:string, props?:PropFormat}) {
// const [openPlay] = useSound("/sfx/s_open.mp3"); // const [openPlay] = useSound("/sfx/s_open.mp3");
// const [impossiblePlay] = useSound("/sfx/s_impossible.mp3"); // const [impossiblePlay] = useSound("/sfx/s_impossible.mp3");
// const [hoverPlay] = useSound("/sfx/s_hover.mp3"); // const [hoverPlay] = useSound("/sfx/s_hover.mp3");
@ -16,7 +16,7 @@ export function SidebarMain({currentPage}:{currentPage:string}) {
<Link <Link
href="/" href="/"
className={currentPage === "/" ? styles.Main_List_CurrentLink : ""} className={currentPage === "/" ? styles.Main_List_CurrentLink : ""}
tabIndex={-1}
// onClick={() => currentPage === "/" ? impossiblePlay() : openPlay()} // onClick={() => currentPage === "/" ? impossiblePlay() : openPlay()}
// onMouseEnter={hoverPlay} // onMouseEnter={hoverPlay}
> >
@ -25,23 +25,31 @@ export function SidebarMain({currentPage}:{currentPage:string}) {
<span>Root</span> <span>Root</span>
</div> </div>
</Link> </Link>
<Link
href="/chat/"
className={currentPage.includes("/chat/") ? styles.Main_List_CurrentLink : ""}
>
<div className={`fw ${styles.Main_List_Button}`}>
<span className="icon">chat</span>
<span>Chat</span>
<span className={styles.Main_List_Button_Badge}>NEW</span>
</div>
</Link>
<Link <Link
href="/jams/" href="/jams/"
className={currentPage.includes("/jams/") ? styles.Main_List_CurrentLink : ""} className={currentPage.includes("/jams/") ? styles.Main_List_CurrentLink : ""}
tabIndex={-1}
// onClick={() => currentPage === "/" ? impossiblePlay() : openPlay()}
// onMouseEnter={hoverPlay}
> >
<div className={`fw ${styles.Main_List_Button}`}> <div className={`fw ${styles.Main_List_Button}`}>
<span className="icon">format_paint</span> <span className="icon">format_paint</span>
<span>Jams</span> <span>Jams</span>
<span className={styles.Main_List_Button_Badge}>NEW</span>
</div> </div>
</Link> </Link>
<Link <Link
href="/search/" href="/search/"
className={currentPage.includes("/search/") ? styles.Main_List_CurrentLink : ""} className={currentPage.includes("/search/") ? styles.Main_List_CurrentLink : ""}
tabIndex={-1}
// onClick={() => currentPage === "/" ? impossiblePlay() : openPlay()} // onClick={() => currentPage === "/" ? impossiblePlay() : openPlay()}
// onMouseEnter={hoverPlay} // onMouseEnter={hoverPlay}
> >
@ -53,7 +61,7 @@ export function SidebarMain({currentPage}:{currentPage:string}) {
<Link <Link
href="/projects/" href="/projects/"
className={currentPage.includes("/projects/") ? styles.Main_List_CurrentLink : ""} className={currentPage.includes("/projects/") ? styles.Main_List_CurrentLink : ""}
tabIndex={-1}
// onClick={() => currentPage.includes("/projects/") ? impossiblePlay() : openPlay()} // onClick={() => currentPage.includes("/projects/") ? impossiblePlay() : openPlay()}
// onMouseEnter={hoverPlay} // onMouseEnter={hoverPlay}
> >
@ -65,7 +73,7 @@ export function SidebarMain({currentPage}:{currentPage:string}) {
<Link <Link
href="/characters/" href="/characters/"
className={currentPage.includes("/characters/") ? styles.Main_List_CurrentLink : ""} className={currentPage.includes("/characters/") ? styles.Main_List_CurrentLink : ""}
tabIndex={-1}
// onClick={() => currentPage.includes("/characters/") ? impossiblePlay() : openPlay()} // onClick={() => currentPage.includes("/characters/") ? impossiblePlay() : openPlay()}
// onMouseEnter={hoverPlay} // onMouseEnter={hoverPlay}
> >
@ -77,7 +85,7 @@ export function SidebarMain({currentPage}:{currentPage:string}) {
<Link <Link
href="/blog/" href="/blog/"
className={currentPage.includes("/blog/") ? styles.Main_List_CurrentLink : ""} className={currentPage.includes("/blog/") ? styles.Main_List_CurrentLink : ""}
tabIndex={-1}
// onClick={() => currentPage.includes("/blog/") ? impossiblePlay() : openPlay()} // onClick={() => currentPage.includes("/blog/") ? impossiblePlay() : openPlay()}
// onMouseEnter={hoverPlay} // onMouseEnter={hoverPlay}
> >
@ -89,7 +97,7 @@ export function SidebarMain({currentPage}:{currentPage:string}) {
<Link <Link
href="/gallery/" href="/gallery/"
className={currentPage.includes("/gallery/") ? styles.Main_List_CurrentLink : ""} className={currentPage.includes("/gallery/") ? styles.Main_List_CurrentLink : ""}
tabIndex={-1}
// onClick={() => currentPage.includes("/gallery/") ? impossiblePlay() : openPlay()} // onClick={() => currentPage.includes("/gallery/") ? impossiblePlay() : openPlay()}
// onMouseEnter={hoverPlay} // onMouseEnter={hoverPlay}
> >
@ -101,7 +109,7 @@ export function SidebarMain({currentPage}:{currentPage:string}) {
<Link <Link
href="/stories/" href="/stories/"
className={currentPage.includes("/stories/") ? styles.Main_List_CurrentLink : ""} className={currentPage.includes("/stories/") ? styles.Main_List_CurrentLink : ""}
tabIndex={-1}
// onClick={() => currentPage.includes("/stories/") ? impossiblePlay() : openPlay()} // onClick={() => currentPage.includes("/stories/") ? impossiblePlay() : openPlay()}
// onMouseEnter={hoverPlay} // onMouseEnter={hoverPlay}
> >
@ -113,7 +121,7 @@ export function SidebarMain({currentPage}:{currentPage:string}) {
<Link <Link
href="/orgs/" href="/orgs/"
className={currentPage.includes("/orgs/") ? styles.Main_List_CurrentLink : ""} className={currentPage.includes("/orgs/") ? styles.Main_List_CurrentLink : ""}
tabIndex={-1}
// onClick={() => currentPage.includes("/orgs/") ? impossiblePlay() : openPlay()} // onClick={() => currentPage.includes("/orgs/") ? impossiblePlay() : openPlay()}
// onMouseEnter={hoverPlay} // onMouseEnter={hoverPlay}
> >
@ -125,7 +133,7 @@ export function SidebarMain({currentPage}:{currentPage:string}) {
<Link <Link
href="/links/" href="/links/"
className={currentPage.includes("/links/") ? styles.Main_List_CurrentLink : ""} className={currentPage.includes("/links/") ? styles.Main_List_CurrentLink : ""}
tabIndex={-1}
// onClick={() => currentPage.includes("/links/") ? impossiblePlay() : openPlay()} // onClick={() => currentPage.includes("/links/") ? impossiblePlay() : openPlay()}
// onMouseEnter={hoverPlay} // onMouseEnter={hoverPlay}
> >
@ -137,7 +145,7 @@ export function SidebarMain({currentPage}:{currentPage:string}) {
<Link <Link
href="/oao/" href="/oao/"
className={currentPage.includes("/oao/") ? styles.Main_List_CurrentLink : ""} className={currentPage.includes("/oao/") ? styles.Main_List_CurrentLink : ""}
tabIndex={-1}
// onClick={() => currentPage.includes("/oao/") ? impossiblePlay() : openPlay()} // onClick={() => currentPage.includes("/oao/") ? impossiblePlay() : openPlay()}
// onMouseEnter={hoverPlay} // onMouseEnter={hoverPlay}
> >
@ -149,7 +157,7 @@ export function SidebarMain({currentPage}:{currentPage:string}) {
<Link <Link
href="/about/" href="/about/"
className={currentPage.includes("/about/") ? styles.Main_List_CurrentLink : ""} className={currentPage.includes("/about/") ? styles.Main_List_CurrentLink : ""}
tabIndex={-1}
// onClick={() => currentPage.includes("/about/") ? impossiblePlay() : openPlay()} // onClick={() => currentPage.includes("/about/") ? impossiblePlay() : openPlay()}
// onMouseEnter={hoverPlay} // onMouseEnter={hoverPlay}
> >