From 2dc42c211175464e26208bfe86412a01e825b4de Mon Sep 17 00:00:00 2001 From: Willi <83978878+itswilliboy@users.noreply.github.com> Date: Tue, 24 Dec 2024 14:58:47 +0100 Subject: [PATCH 01/15] Start implementing new message UI --- src/components/ChatBar.tsx | 3 +-- src/components/ColourModeSwitch.tsx | 2 +- src/components/Message.tsx | 28 ++++++++++++++++++++-------- 3 files changed, 22 insertions(+), 11 deletions(-) diff --git a/src/components/ChatBar.tsx b/src/components/ChatBar.tsx index b1eaf06..0682b87 100644 --- a/src/components/ChatBar.tsx +++ b/src/components/ChatBar.tsx @@ -1,7 +1,6 @@ import { createMemo, createSignal } from "solid-js"; import type { MessageType } from "~/types"; - import type { JSX, Setter } from "solid-js"; export default function ChatBar({ setMessages }: { setMessages: Setter }) { @@ -23,7 +22,7 @@ export default function ChatBar({ setMessages }: { setMessages: Setter -
+
return ( + )} +
+ ); +}; + export default function Login() { - return
Hello, world.
; + const [username, setUsername] = createSignal(""); + const [password, setPassword] = createSignal(""); + + const onSubmit = (event: SubmitEvent) => { + event.preventDefault(); + // const data = new FormData(event.currentTarget as HTMLFormElement); + (event.currentTarget as HTMLFormElement).reset(); + setUsername(""); + setPassword(""); + }; + + return ( + <> +
+
+
+
+
+

+ Welcome to{" "} + + Lamna + +

+
+ + + +
+
+ + +
+ +
+ Username: {username()} +
+ Password: {password()} + +
+ + I forgot my password. + +
+
+ + ); } From c908d6ba2a1f98ddbd4cbad080ba0721943f3310 Mon Sep 17 00:00:00 2001 From: cyrus01337 Date: Fri, 21 Feb 2025 02:09:57 +0000 Subject: [PATCH 11/15] Add properties type for context provider --- src/libs/GlobalProvider.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/libs/GlobalProvider.tsx b/src/libs/GlobalProvider.tsx index 54cbb05..c3d6bee 100644 --- a/src/libs/GlobalProvider.tsx +++ b/src/libs/GlobalProvider.tsx @@ -1,20 +1,20 @@ import { createWS } from "@solid-primitives/websocket"; import moment from "moment"; -import { createSignal } from "solid-js"; +import { createSignal, ParentProps } from "solid-js"; import { GlobalContext } from "~/libs/context"; import { setStore, store, StoreData } from "~/libs/store"; import type { MessageType } from "~/types"; +type Properties = ParentProps; type APIMessageResponse = { id: string; content: string; timestamp: string; }; -// TODO: Fix type, don't use `any` -export default function GlobalProvider(props: any) { +export default function GlobalProvider(properties: Properties) { const websocket = createWS(import.meta.env.VITE_WS_URL); const [messages, setMessages] = createSignal([]); @@ -63,5 +63,5 @@ export default function GlobalProvider(props: any) { messages: { getter: messages, setter: setMessages }, } satisfies StoreData); - return {props.children}; + return {properties.children}; } From 7d1ba80f92193d525adb708893bad3013a09fb5f Mon Sep 17 00:00:00 2001 From: ItsWilliboy <83978878+itswilliboy@users.noreply.github.com> Date: Fri, 21 Feb 2025 16:53:28 +0100 Subject: [PATCH 12/15] Login + Signup, various other changes aimed to increase functionality (subject to structural change) --- src/components/ChatBar.tsx | 26 +++---- src/components/LoginTextInput.tsx | 47 +++++++++++++ src/index.tsx | 2 + src/layouts/Root.tsx | 49 +++++++++++++- src/libs/GlobalProvider.tsx | 20 ++++-- src/libs/store.ts | 21 +++++- src/pages/Home.tsx | 10 ++- src/pages/Login.tsx | 109 +++++++++++++++--------------- src/pages/Signup.tsx | 82 ++++++++++++++++++++++ src/types.d.ts | 5 ++ 10 files changed, 289 insertions(+), 82 deletions(-) create mode 100644 src/components/LoginTextInput.tsx create mode 100644 src/pages/Signup.tsx diff --git a/src/components/ChatBar.tsx b/src/components/ChatBar.tsx index 8da2ad2..f3bdea4 100644 --- a/src/components/ChatBar.tsx +++ b/src/components/ChatBar.tsx @@ -1,12 +1,4 @@ -import moment from "moment"; -import { useContext } from "solid-js"; - -import { GlobalContext } from "~/libs/context"; -import { MessageType } from "~/types"; - export default function ChatBar() { - const context = useContext(GlobalContext)!; - const formHandler = async (event: SubmitEvent) => { event.preventDefault(); @@ -18,7 +10,7 @@ export default function ChatBar() { if (!content) return; try { - const response = await fetch( + /*const response =*/ await fetch( `${import.meta.env.VITE_BACKEND_URL}/api/v1/channels/0/messages`, { method: "POST", @@ -28,14 +20,14 @@ export default function ChatBar() { }, ); - const data = await response.json(); - const msg = { - author: "Lamna User", - content: data.Content, - id: data.MessageID, - timestamp: moment(data.Timestamp), - } satisfies MessageType; - context.store.messages?.setter([...context.store.messages.getter(), msg]); + // const data = await response.json(); + // const msg = { + // author: "Lamna User", + // content: data.Content, + // id: data.MessageID, + // timestamp: moment(data.Timestamp), + // } satisfies MessageType; + // context.store.messages?.setter([...context.store.messages.getter(), msg]); } finally { (event.target as HTMLFormElement).reset(); } diff --git a/src/components/LoginTextInput.tsx b/src/components/LoginTextInput.tsx new file mode 100644 index 0000000..4672f27 --- /dev/null +++ b/src/components/LoginTextInput.tsx @@ -0,0 +1,47 @@ +import { AiOutlineEye, AiOutlineEyeInvisible } from "solid-icons/ai"; +import { createSignal } from "solid-js"; + +export default function LoginTextInput({ + type, + placeholder, + name, + required = true, + ref, +}: { + type: "text" | "password" | "email"; + placeholder: string; + name: string; + required?: boolean; + ref?: any; +}) { + const [isHidden, setIsHidden] = createSignal(true); + + const getType = () => { + if (type === "password") { + return isHidden() ? "password" : "text"; + } + }; + + return ( +
+ + {type === "password" && ( + + )} +
+ ); +} diff --git a/src/index.tsx b/src/index.tsx index 111d4c5..b4406ca 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -9,6 +9,7 @@ import RootLayout from "~/layouts/Root"; import GlobalProvider from "~/libs/GlobalProvider"; import Home from "~/pages/Home"; import Login from "~/pages/Login"; +import Signup from "~/pages/Signup"; render( () => ( @@ -16,6 +17,7 @@ render( + ), diff --git a/src/layouts/Root.tsx b/src/layouts/Root.tsx index 032bbc5..c932655 100644 --- a/src/layouts/Root.tsx +++ b/src/layouts/Root.tsx @@ -1,11 +1,36 @@ -import { A } from "@solidjs/router"; +import { A, useNavigate } from "@solidjs/router"; import clsx from "clsx"; -import { createSignal } from "solid-js"; +import { createEffect, createSignal, useContext } from "solid-js"; import ColourModeSwitch from "~/components/ColourModeSwitch"; +import { GlobalContext } from "~/libs/context"; +import { tempGetCookie, tempSetCookie } from "~/libs/store"; export default function RootLayout(props: any) { const [isDarkMode, setIsDarkMode] = createSignal(true); + const context = useContext(GlobalContext)!; + const navigate = useNavigate(); + + createEffect(async () => { + const cookie = tempGetCookie("lamna-auth"); + if (cookie) { + context.store.auth?.setter({ auth: cookie, refresh: "" }); + context.store?.isAuthed?.setter(true); + + let resp; + try { + resp = await fetch(import.meta.env.VITE_BACKEND_URL + `/api/v1/me`, { + headers: { Authorisation: `Bearer ${cookie}` }, + }); + } catch (err) { + console.error(err); + return; + } + + const json = await resp.json(); + context.store.user?.setter({ id: json.id, username: json.username }); + } + }); return (
Home Login
+
+
+
+ User: +
+ {context.store.user?.getter().username} +
+ +
+
diff --git a/src/libs/GlobalProvider.tsx b/src/libs/GlobalProvider.tsx index 54cbb05..7c65030 100644 --- a/src/libs/GlobalProvider.tsx +++ b/src/libs/GlobalProvider.tsx @@ -1,11 +1,11 @@ import { createWS } from "@solid-primitives/websocket"; import moment from "moment"; -import { createSignal } from "solid-js"; +import { createSignal, JSX } from "solid-js"; import { GlobalContext } from "~/libs/context"; import { setStore, store, StoreData } from "~/libs/store"; -import type { MessageType } from "~/types"; +import type { CachedUser, MessageType } from "~/types"; type APIMessageResponse = { id: string; @@ -13,8 +13,7 @@ type APIMessageResponse = { timestamp: string; }; -// TODO: Fix type, don't use `any` -export default function GlobalProvider(props: any) { +export default function GlobalProvider(props: { children: JSX.Element }) { const websocket = createWS(import.meta.env.VITE_WS_URL); const [messages, setMessages] = createSignal([]); @@ -55,12 +54,23 @@ export default function GlobalProvider(props: any) { websocket.addEventListener("close", (_: CloseEvent) => { // we need to handle this better, but for now this is fine. - window.location.reload(); + setTimeout(() => { + window.location.reload(); + }, 5000); }); + const [auth, setAuth] = createSignal<{ auth: string; refresh: string }>({ + auth: "", + refresh: "", + }); + const [isAuthed, setIsAuthed] = createSignal(false); + const [user, setUser] = createSignal({ id: "", username: "" }); setStore({ websocket, messages: { getter: messages, setter: setMessages }, + auth: { getter: auth, setter: setAuth }, + isAuthed: { getter: isAuthed, setter: setIsAuthed }, + user: { getter: user, setter: setUser }, } satisfies StoreData); return {props.children}; diff --git a/src/libs/store.ts b/src/libs/store.ts index a8f947d..8c1719d 100644 --- a/src/libs/store.ts +++ b/src/libs/store.ts @@ -1,11 +1,30 @@ import { createStore, SetStoreFunction } from "solid-js/store"; -import type { MessageType, Properties } from "~/types"; +import type { CachedUser, MessageType, Properties } from "~/types"; export type StoreData = { websocket?: WebSocket; messages?: Properties; + auth?: Properties<{ auth: string; refresh: string }>; // TODO: Will probably be moved + isAuthed?: Properties; + user?: Properties; }; export const [store, setStore]: [store: StoreData, setStore: SetStoreFunction] = createStore({}); + +export const tempSetCookie = (token: string) => { + // As you can see, proof-of-concept: + document.cookie = `lamna-auth=${token}; expires=Sat 01 March 2025 00:00:00 UTC; domain=100.88.207.41; path=/;`; +}; + +export const tempGetCookie = (name: string): string => { + const cookies: Record = document.cookie + .split("; ") + .reduce((acc: Record, cookie) => { + const [key, value] = cookie.split("="); + acc[key] = value; + return acc; + }, {}); + return cookies[name]; +}; diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index e46ea9d..f6940a4 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -1,4 +1,5 @@ -import { useContext } from "solid-js"; +import { useNavigate } from "@solidjs/router"; +import { createEffect, useContext } from "solid-js"; import ChatBar from "~/components/ChatBar"; import ChatLog from "~/components/ChatLog"; @@ -6,6 +7,13 @@ import { GlobalContext } from "~/libs/context"; export default function Home() { const context = useContext(GlobalContext)!; + const navigate = useNavigate(); + + createEffect(() => { + if (!context.store.isAuthed?.getter()) { + navigate("/login"); + } + }); return ( <> diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index 97fc4fe..e21fdf2 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -1,51 +1,45 @@ -import { AiOutlineEye, AiOutlineEyeInvisible } from "solid-icons/ai"; -import { createSignal, Setter } from "solid-js"; +import { A, useNavigate } from "@solidjs/router"; +import { createSignal, useContext } from "solid-js"; -const TextInput = ({ - type, - placeholder, - name, - setter, -}: { - type: "text" | "password"; - placeholder: string; - name: string; - setter: Setter; -}) => { - const [isHidden, setIsHidden] = createSignal(true); +import LoginTextInput from "~/components/LoginTextInput"; +import { GlobalContext } from "~/libs/context"; +import { tempSetCookie } from "~/libs/store"; - return ( -
- setter(event.target.value)} - type={type === "text" ? "text" : isHidden() ? "password" : "text"} - name={name} - placeholder={placeholder} - class="ring-gradient group peer w-full rounded-lg bg-white/10 p-3 font-semibold text-white outline-none ring-purple-600 transition focus:ring-2" - /> - {type === "password" && ( - - )} -
- ); -}; +import type { CachedUser } from "~/types"; export default function Login() { - const [username, setUsername] = createSignal(""); - const [password, setPassword] = createSignal(""); + let passwordInput!: HTMLInputElement; + const navigate = useNavigate(); + const context = useContext(GlobalContext)!; - const onSubmit = (event: SubmitEvent) => { + const [errorMessage, setErrorMessage] = createSignal(null); + + const onSubmit = async (event: SubmitEvent) => { event.preventDefault(); - // const data = new FormData(event.currentTarget as HTMLFormElement); - (event.currentTarget as HTMLFormElement).reset(); - setUsername(""); - setPassword(""); + const data = new FormData(event.currentTarget as HTMLFormElement); + + const payload = { + username: data.get("username"), + password: data.get("password"), + }; + const resp = await fetch(import.meta.env.VITE_BACKEND_URL + `/api/v1/login`, { + method: "POST", + body: JSON.stringify(payload), + }); + + if (resp.status === 401) { + passwordInput.value = ""; + setErrorMessage("Wrong username or password."); + } else if (resp.status === 200) { + const json = await resp.json(); + context.store.auth?.setter({ auth: json.auth_token, refresh: json.refresh_token }); // TODO: probably move + + context.store.isAuthed?.setter(true); + navigate("/"); + + context.store.user?.setter({ id: json.id, username: json.username } satisfies CachedUser); + tempSetCookie(json.auth_token); + } }; return ( @@ -57,21 +51,21 @@ export default function Login() { >
-
+

Welcome to{" "} Lamna

-
+
- - +
@@ -87,17 +81,20 @@ export default function Login() { Log in
- Username: {username()} -
- Password: {password()}
- - I forgot my password. - + {errorMessage() &&

{errorMessage()}

} +
+ + I forgot my password. + +

+ Don't have an account? + + Sign up. + +

+
diff --git a/src/pages/Signup.tsx b/src/pages/Signup.tsx new file mode 100644 index 0000000..dbf8131 --- /dev/null +++ b/src/pages/Signup.tsx @@ -0,0 +1,82 @@ +import { A, useNavigate } from "@solidjs/router"; +import { createSignal, useContext } from "solid-js"; + +import LoginTextInput from "~/components/LoginTextInput"; +import { GlobalContext } from "~/libs/context"; +import { tempSetCookie } from "~/libs/store"; +import { CachedUser } from "~/types"; + +export default function Signup() { + const navigate = useNavigate(); + const context = useContext(GlobalContext)!; + + const [errorMessage, setErrorMessage] = createSignal(null); + + const onSubmit = async (event: SubmitEvent) => { + event.preventDefault(); + const data = new FormData(event.currentTarget as HTMLFormElement); + + const payload = { + username: data.get("username"), + password: data.get("password"), + email: data.get("email"), + }; + const resp = await fetch(import.meta.env.VITE_BACKEND_URL + `/api/v1/signup`, { + method: "POST", + body: JSON.stringify(payload), + }); + + if (resp.status === 409) { + setErrorMessage("This username or email is already taken."); + } else if (resp.status === 200) { + const json = await resp.json(); + context.store.auth?.setter({ auth: json.auth_token, refresh: json.refresh_token }); // TODO: Maybe use other solution + context.store.isAuthed?.setter(true); + navigate("/"); + + context.store.user?.setter({ id: json.id, username: json.username } satisfies CachedUser); + tempSetCookie(json.auth_token); + } + }; + + return ( + <> +
+
+
+
+
+

Create an account

+
+
+ + + +
+ +
+ +
+ {/* TODO: Fix styling for error text*/} + {errorMessage() &&

{errorMessage()}

} +
+

+ Already have an acount? + + Log in. + +

+
+
+
+ + ); +} diff --git a/src/types.d.ts b/src/types.d.ts index 849b049..2f61c39 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -13,3 +13,8 @@ export interface MessageType { content: string; timestamp: Moment; } + +export interface CachedUser { + id: string; + username: string; +} From 853a3fb2d318fe68c91e29a2a9f1ce6b1bcaf08f Mon Sep 17 00:00:00 2001 From: ItsWilliboy <83978878+itswilliboy@users.noreply.github.com> Date: Fri, 21 Feb 2025 20:39:16 +0100 Subject: [PATCH 13/15] Clean up store-code --- src/components/ChatBar.tsx | 29 +++++++++++------------------ src/components/ChatLog.tsx | 14 +++++++------- src/layouts/Root.tsx | 21 +++++++++------------ src/libs/GlobalProvider.tsx | 29 ++++++++--------------------- src/libs/store.ts | 15 +++++++++------ src/pages/Home.tsx | 9 ++++----- src/pages/Login.tsx | 14 +++++--------- src/pages/Signup.tsx | 14 ++++++-------- 8 files changed, 59 insertions(+), 86 deletions(-) diff --git a/src/components/ChatBar.tsx b/src/components/ChatBar.tsx index f3bdea4..d67223f 100644 --- a/src/components/ChatBar.tsx +++ b/src/components/ChatBar.tsx @@ -1,3 +1,8 @@ +import moment from "moment"; + +import { setStore, store } from "~/libs/store"; +import { MessageType } from "~/types"; + export default function ChatBar() { const formHandler = async (event: SubmitEvent) => { event.preventDefault(); @@ -10,24 +15,12 @@ export default function ChatBar() { if (!content) return; try { - /*const response =*/ await fetch( - `${import.meta.env.VITE_BACKEND_URL}/api/v1/channels/0/messages`, - { - method: "POST", - body: JSON.stringify({ - content, - }), - }, - ); - - // const data = await response.json(); - // const msg = { - // author: "Lamna User", - // content: data.Content, - // id: data.MessageID, - // timestamp: moment(data.Timestamp), - // } satisfies MessageType; - // context.store.messages?.setter([...context.store.messages.getter(), msg]); + await fetch(`${import.meta.env.VITE_BACKEND_URL}/api/v1/channels/0/messages`, { + method: "POST", + body: JSON.stringify({ + content, + }), + }); } finally { (event.target as HTMLFormElement).reset(); } diff --git a/src/components/ChatLog.tsx b/src/components/ChatLog.tsx index 69c1e4a..d793c0e 100644 --- a/src/components/ChatLog.tsx +++ b/src/components/ChatLog.tsx @@ -1,4 +1,4 @@ -import { Accessor, createEffect, For } from "solid-js"; +import { createEffect, For } from "solid-js"; import Message from "~/components/Message"; @@ -7,7 +7,9 @@ import type { Moment } from "moment"; import "~/assets/styles/ChatLog.css"; -export default function ChatLog({ messages }: { messages: Accessor }) { +import { store } from "~/libs/store"; + +export default function ChatLog() { let elementReference!: HTMLDivElement; const scrollToEnd = (_: any) => @@ -17,15 +19,13 @@ export default function ChatLog({ messages }: { messages: Accessor { - scrollToEnd(messages()); + scrollToEnd(store.messages); }); const isConsecutive = (before: Moment, after: Moment) => after.diff(before) < 1 * 60 * 1000; const shouldBeGrouped = (message: MessageType, idx: number): boolean => { - const message_store = messages(); - - const afterMessage = message_store[idx - 1]; + const afterMessage = store.messages[idx - 1]; if (!afterMessage) return false; return isConsecutive(afterMessage.timestamp, message.timestamp); @@ -37,7 +37,7 @@ export default function ChatLog({ messages }: { messages: Accessor - + {(data, index) => }
diff --git a/src/layouts/Root.tsx b/src/layouts/Root.tsx index c932655..4ee9c78 100644 --- a/src/layouts/Root.tsx +++ b/src/layouts/Root.tsx @@ -1,22 +1,19 @@ import { A, useNavigate } from "@solidjs/router"; import clsx from "clsx"; -import { createEffect, createSignal, useContext } from "solid-js"; +import { createEffect, createSignal } from "solid-js"; import ColourModeSwitch from "~/components/ColourModeSwitch"; -import { GlobalContext } from "~/libs/context"; -import { tempGetCookie, tempSetCookie } from "~/libs/store"; +import { setStore, store, tempGetCookie, tempSetCookie } from "~/libs/store"; export default function RootLayout(props: any) { const [isDarkMode, setIsDarkMode] = createSignal(true); - const context = useContext(GlobalContext)!; const navigate = useNavigate(); createEffect(async () => { const cookie = tempGetCookie("lamna-auth"); if (cookie) { - context.store.auth?.setter({ auth: cookie, refresh: "" }); - context.store?.isAuthed?.setter(true); - + setStore("auth", { auth: cookie, refresh: "" }); + setStore("isAuthed", true); let resp; try { resp = await fetch(import.meta.env.VITE_BACKEND_URL + `/api/v1/me`, { @@ -28,7 +25,7 @@ export default function RootLayout(props: any) { } const json = await resp.json(); - context.store.user?.setter({ id: json.id, username: json.username }); + setStore("user", { username: json.username, id: json.id }); } }); @@ -51,14 +48,14 @@ export default function RootLayout(props: any) {
User:
- {context.store.user?.getter().username} + {store.user?.username}
diff --git a/src/components/LoginTextInput.tsx b/src/components/LoginTextInput.tsx index 4672f27..99e8979 100644 --- a/src/components/LoginTextInput.tsx +++ b/src/components/LoginTextInput.tsx @@ -1,47 +1,63 @@ import { AiOutlineEye, AiOutlineEyeInvisible } from "solid-icons/ai"; -import { createSignal } from "solid-js"; +import { createEffect, createSignal } from "solid-js"; +import { z } from "zod"; -export default function LoginTextInput({ +import type { Accessor, ComponentProps } from "solid-js"; + +type KeyOfFieldErrors = keyof z.typeToFlattenedError< + z.TypeOf +>["fieldErrors"]; + +type Props = ComponentProps<"input"> & { + error: Accessor> | null>; +}; +// this has gotta be some kind of warcrime + +export default function LoginTextInput({ + error, type, - placeholder, name, - required = true, - ref, -}: { - type: "text" | "password" | "email"; - placeholder: string; - name: string; - required?: boolean; - ref?: any; -}) { + ...props +}: Props) { const [isHidden, setIsHidden] = createSignal(true); + const [errorMessage, setErrorMessage] = createSignal(undefined); + + createEffect(() => { + const msg = error()?.fieldErrors[name as KeyOfFieldErrors]?.[0]; + setErrorMessage(msg); + }); - const getType = () => { + const getType = (): string => { if (type === "password") { return isHidden() ? "password" : "text"; } + + return "text"; }; return ( -
- - {type === "password" && ( - - )} +
+
+ + + {type === "password" && ( + + )} +
+ + {errorMessage() &&

*{errorMessage()}

}
); } diff --git a/src/components/Message.tsx b/src/components/Message.tsx index 2261e56..cba80fb 100644 --- a/src/components/Message.tsx +++ b/src/components/Message.tsx @@ -1,6 +1,6 @@ import clsx from "clsx"; -import type { MessageType } from "~/types"; +import { Message as MessageType } from "~/types"; export default function Message({ message, grouped }: { message: MessageType; grouped: boolean }) { return ( @@ -22,10 +22,11 @@ export default function Message({ message, grouped }: { message: MessageType; gr
- {message.author} + {message.author.username}
+

{message.content}

diff --git a/src/index.tsx b/src/index.tsx index b4406ca..2a3efa3 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -10,14 +10,19 @@ import GlobalProvider from "~/libs/GlobalProvider"; import Home from "~/pages/Home"; import Login from "~/pages/Login"; import Signup from "~/pages/Signup"; +import Test from "~/pages/Test"; +import NormalView from "./layouts/NormalView"; render( () => ( - + + + + ), diff --git a/src/layouts/NormalView.tsx b/src/layouts/NormalView.tsx new file mode 100644 index 0000000..9a2eeb7 --- /dev/null +++ b/src/layouts/NormalView.tsx @@ -0,0 +1,38 @@ +import { createWS } from "@solid-primitives/websocket"; +import moment from "moment"; + +import { APIClient } from "~/libs/client"; +import { setStore } from "~/libs/store"; + +export default function NormalView(props: any) { + const websocket = createWS(import.meta.env.VITE_WS_URL); + + APIClient.channelHistory(1).then(({ data }) => { + setStore("messages", messages => [...messages, ...data]); + }); + + websocket.addEventListener("message", (event: MessageEvent) => { + const message = JSON.parse(event.data); + const newMessage = { + ...message, + timestamp: moment(message.timestamp, moment.ISO_8601), + }; + + setStore("messages", messages => [...messages, newMessage]); + }); + + websocket.addEventListener("close", (_: CloseEvent) => { + // TODO: we need to handle this better, but for now this is fine. + setTimeout(() => { + window.location.reload(); + }, 10000); + }); + + setStore("websocket", websocket); + return ( + <> + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + {props.children} + + ); +} diff --git a/src/layouts/Root.tsx b/src/layouts/Root.tsx index 4ee9c78..cd1e1cb 100644 --- a/src/layouts/Root.tsx +++ b/src/layouts/Root.tsx @@ -1,71 +1,16 @@ -import { A, useNavigate } from "@solidjs/router"; import clsx from "clsx"; -import { createEffect, createSignal } from "solid-js"; - -import ColourModeSwitch from "~/components/ColourModeSwitch"; -import { setStore, store, tempGetCookie, tempSetCookie } from "~/libs/store"; +import { createSignal } from "solid-js"; export default function RootLayout(props: any) { const [isDarkMode, setIsDarkMode] = createSignal(true); - const navigate = useNavigate(); - - createEffect(async () => { - const cookie = tempGetCookie("lamna-auth"); - if (cookie) { - setStore("auth", { auth: cookie, refresh: "" }); - setStore("isAuthed", true); - let resp; - try { - resp = await fetch(import.meta.env.VITE_BACKEND_URL + `/api/v1/me`, { - headers: { Authorisation: `Bearer ${cookie}` }, - }); - } catch (err) { - console.error(err); - return; - } - - const json = await resp.json(); - setStore("user", { username: json.username, id: json.id }); - } - }); return (
-
-
- -
- Home - Login -
-
-
-
- User: -
- {store.user?.username} -
- -
-
-
-
- {props.children}
); diff --git a/src/libs/GlobalProvider.tsx b/src/libs/GlobalProvider.tsx index 62cb53a..b559de7 100644 --- a/src/libs/GlobalProvider.tsx +++ b/src/libs/GlobalProvider.tsx @@ -3,59 +3,33 @@ import moment from "moment"; import { ParentProps } from "solid-js"; import { setStore } from "~/libs/store"; - -import type { MessageType } from "~/types"; +import { APIClient } from "./client"; type Properties = ParentProps; -type APIMessageResponse = { - id: string; - content: string; - timestamp: string; -}; export default function GlobalProvider(properties: Properties) { + return <>{properties.children}; const websocket = createWS(import.meta.env.VITE_WS_URL); - fetch(`${import.meta.env.VITE_BACKEND_URL}/api/v1/channels/0/messages`).then(data => - data.json().then((json: APIMessageResponse[]) => { - let toAdd = [] as MessageType[]; - - json.forEach(i => { - const date = moment(i.timestamp, moment.ISO_8601); - - const msg = { - author: "Lamna User", - content: i.content, - id: i.id, - timestamp: date, - } satisfies MessageType; - - toAdd.push(msg); - }); - - toAdd.sort((a, b) => Number(a.timestamp) - Number(b.timestamp)); - setStore("messages", messages => [...messages, ...toAdd]); - }), - ); + APIClient.channelHistory(1).then(({ data }) => { + setStore("messages", messages => [...messages, ...data]); + }); websocket.addEventListener("message", (event: MessageEvent) => { const message = JSON.parse(event.data); - const newMessage = { - id: message.id, - author: "Lamna User", - content: message.content, + ...message, timestamp: moment(message.timestamp, moment.ISO_8601), - } satisfies MessageType; + }; setStore("messages", messages => [...messages, newMessage]); }); websocket.addEventListener("close", (_: CloseEvent) => { - // we need to handle this better, but for now this is fine. + // TODO: we need to handle this better, but for now this is fine. setTimeout(() => { window.location.reload(); - }, 5000); + }, 10000); }); setStore("websocket", websocket); diff --git a/src/libs/client.ts b/src/libs/client.ts new file mode 100644 index 0000000..b945758 --- /dev/null +++ b/src/libs/client.ts @@ -0,0 +1,192 @@ +import moment from "moment"; + +import { Message, User } from "~/types"; +import { tempGetCookie, tempSetCookie } from "./store"; + +type Method = "GET" | "POST" | "PUT" | "DELETE" | "PATCH"; +type RequestResponse = { data: T; status: number }; + +class Route { + private static BASE = `${import.meta.env.VITE_BACKEND_URL}/api/v1`; + + method: Method; + path: string; + query?: Record; + slug?: Record; + + constructor( + method: Method, + path: string, + options?: { query?: Record; slug?: Record }, + ) { + this.method = method; + this.path = path; + this.query = options?.query; + this.slug = options?.slug; + } + + private substitute(path: string, lookup: Record): string { + return path.replace(/{([^}]+)}/g, (_, key) => { + const slug = lookup[key]; + if (!slug) { + throw new Error(`Could not substitute ${key} in ${path}.`); + } + + return slug; + }); + } + + get url() { + const path = this.slug ? this.substitute(this.path, this.slug) : this.path; + + let url = `${Route.BASE}${path}`; + if (this.query) { + const params = new URLSearchParams(this.query); + url += `?${params.toString()}`; + } + + return url; + } +} + +class Client { + private token?: string; + private refresh_token?: string; + + private getHeaders(route: Route): Record { + if (route.path === "/refresh") { + if (!this.refresh_token) { + this.refresh_token = tempGetCookie("lamna-refresh"); + } + return { Authorisation: `Bearer ${this.refresh_token}` }; + } else { + if (!this.token) { + this.token = tempGetCookie("lamna-auth"); + } + return { Authorisation: `Bearer ${this.token}` }; + } + } + + private async request( + route: Route, + body?: Record, + options?: { + refresh: boolean; + }, + ): Promise> { + const authHeaders = this.getHeaders(route); + + const response = await fetch(route.url, { + body: body ? JSON.stringify(body) : null, + method: route.method, + headers: { + "Content-Type": "application/json", + "User-Agent": `LamnaClient (v0)`, + ...authHeaders, + }, + credentials: "include", + }); + + if (!response.ok) { + if (response.status === 401) { + if (options?.refresh ?? true) { + await this.refresh(); + return await this.request(route, body); + } + } + const error = await response.text(); + + throw new Error(error); + } + + return { + data: await response.json(), + status: response.status, + }; + } + + public async refresh() { + const route = new Route("POST", "/refresh"); + + type RefreshResponse = { auth_token: string; refresh_token: string }; + const { data } = await this.request(route); + + this.token = data.auth_token; + this.refresh_token = data.refresh_token; + + tempSetCookie("lamna-auth", this.token); + tempSetCookie("lamna-refresh", this.refresh_token); + } + + public async login(username: string, password: string) { + const route = new Route("POST", "/login"); + + type LoginResponse = { + user: User; + auth_token: string; + refresh_token: string; + }; + + const response = await this.request( + route, + { + username, + password, + }, + { refresh: false }, + ); + + return response; + } + + public async me() { + const route = new Route("GET", "/@me"); + + const response = await this.request(route); + return response; + } + + public async createMessage(content: string, channel_id: number) { + const route = new Route("POST", "/channels/{channel_id}/messages", { slug: { channel_id } }); + + const response = await this.request(route, { content }); + + return response; + } + + public async channelHistory(channel_id: number) { + const route = new Route("GET", "/channels/{channel_id}/messages", { slug: { channel_id } }); + + const response = await this.request(route); + + response.data = response.data.map(message => { + return { + ...message, + timestamp: moment(message.timestamp, moment.ISO_8601), + } satisfies Message; + }); + + return response; + } + + public async signup(username: string, email: string, password: string) { + const route = new Route("POST", "/signup"); + + type SignupResponse = { + user: User; + auth_token: string; + refresh_token: string; + }; + + const response = await this.request(route, { + username, + email, + password, + }); + + return response; + } +} + +// probably delegate creation to iother file +export const APIClient = new Client(); diff --git a/src/libs/store.ts b/src/libs/store.ts index 5180bc3..2f77760 100644 --- a/src/libs/store.ts +++ b/src/libs/store.ts @@ -1,10 +1,10 @@ import { createStore, SetStoreFunction } from "solid-js/store"; -import type { MessageType } from "~/types"; +import type { Message } from "~/types"; export type StoreData = { websocket?: WebSocket; - messages: MessageType[]; + messages: Message[]; auth?: { auth: string; refresh: string }; isAuthed: boolean; user?: { username: string; id: string }; @@ -16,9 +16,10 @@ export const [store, setStore]: [store: StoreData, setStore: SetStoreFunction { +export const tempSetCookie = (name: string, token: string) => { + return; // As you can see, proof-of-concept: - document.cookie = `lamna-auth=${token}; expires=Sat 01 March 2025 00:00:00 UTC; domain=100.88.207.41; path=/;`; + document.cookie = `${name}=${token}; expires=Sat 01 December 2025 00:00:00 UTC; domain=100.88.207.41; path=/;`; }; export const tempGetCookie = (name: string): string => { diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index 34ad9c0..d6e0890 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -1,40 +1,44 @@ import { A, useNavigate } from "@solidjs/router"; import { createSignal } from "solid-js"; +import { z } from "zod"; import LoginTextInput from "~/components/LoginTextInput"; +import { APIClient } from "~/libs/client"; import { setStore, tempSetCookie } from "~/libs/store"; +const LoginData = z.object({ + username: z.string().nonempty("Invalid username").trim(), + password: z.string().nonempty("Invalid password").trim(), +}); + export default function Login() { let passwordInput!: HTMLInputElement; const navigate = useNavigate(); - const [errorMessage, setErrorMessage] = createSignal(null); + const [formErrors, setFormError] = createSignal + > | null>(null); const onSubmit = async (event: SubmitEvent) => { event.preventDefault(); const data = new FormData(event.currentTarget as HTMLFormElement); - const payload = { + const parsed = LoginData.safeParse({ username: data.get("username"), password: data.get("password"), - }; - const resp = await fetch(import.meta.env.VITE_BACKEND_URL + `/api/v1/login`, { - method: "POST", - body: JSON.stringify(payload), }); - if (resp.status === 401) { - passwordInput.value = ""; - setErrorMessage("Wrong username or password."); - } else if (resp.status === 200) { - const json = await resp.json(); - setStore("auth", { auth: json.auth_token, refresh: json.refresh_token }); + if (!parsed.success) { + return setFormError(parsed.error.flatten()); + } - setStore("isAuthed", true); - navigate("/"); + const { status } = await APIClient.login(parsed.data.username, parsed.data.password); - setStore("user", { username: json.username, id: json.id }); - tempSetCookie(json.auth_token); + if (status === 401) { + passwordInput.value = ""; + return setFormError({ formErrors: ["Wrong username or password."], fieldErrors: {} }); + } else if (status === 200) { + navigate("/"); } }; @@ -46,6 +50,7 @@ export default function Login() { class="h-full w-full scale-125 bg-gradient-to-br from-purple-600 to-pink-600 opacity-40" >
+

@@ -54,15 +59,24 @@ export default function Login() { Lamna

+
- + + +
@@ -79,7 +93,11 @@ export default function Login() {
- {errorMessage() &&

{errorMessage()}

} + + {formErrors()?.["formErrors"][0] && ( +

{formErrors()?.["formErrors"][0]}

+ )} +
I forgot my password. diff --git a/src/pages/Signup.tsx b/src/pages/Signup.tsx index 4c32851..06369b4 100644 --- a/src/pages/Signup.tsx +++ b/src/pages/Signup.tsx @@ -1,39 +1,57 @@ import { A, useNavigate } from "@solidjs/router"; import { createSignal } from "solid-js"; +import { z } from "zod"; import LoginTextInput from "~/components/LoginTextInput"; +import { APIClient } from "~/libs/client"; import { setStore, tempSetCookie } from "~/libs/store"; +const SignInData = z.object({ + email: z.string().nonempty().email("Invalid email"), + username: z.string().nonempty("Invalid username").trim(), + password: z.string().nonempty("Invalid password").min(8).trim(), +}); + export default function Signup() { const navigate = useNavigate(); - const [errorMessage, setErrorMessage] = createSignal(null); + const [formErrors, setFormError] = createSignal + > | null>(null); const onSubmit = async (event: SubmitEvent) => { event.preventDefault(); + const data = new FormData(event.currentTarget as HTMLFormElement); - const payload = { + const parsed = SignInData.safeParse({ + email: data.get("email"), username: data.get("username"), password: data.get("password"), - email: data.get("email"), - }; - const resp = await fetch(import.meta.env.VITE_BACKEND_URL + `/api/v1/signup`, { - method: "POST", - body: JSON.stringify(payload), }); - if (resp.status === 409) { - setErrorMessage("This username or email is already taken."); - } else if (resp.status === 200) { - const json = await resp.json(); - setStore("auth", { auth: json.auth_token, refresh: json.refresh_token }); + if (!parsed.success) { + return setFormError(parsed.error.flatten()); + } + + const parsedData = parsed.data!; + + const { data: resp, status } = await APIClient.signup( + parsedData.username, + parsedData.email, + parsedData.password, + ); + + if (status === 409) { + setFormError({ formErrors: ["User already exists"], fieldErrors: {} }); + } else if (status === 200) { + setStore("auth", { auth: resp.auth_token, refresh: resp.refresh_token }); setStore("isAuthed", true); navigate("/"); - setStore("user", { username: json.username, id: json.id }); - tempSetCookie(json.auth_token); + setStore("user", { username: resp.user.username, id: resp.user.id }); + tempSetCookie("lamna-auth", resp.auth_token); } }; @@ -45,14 +63,28 @@ export default function Signup() { class="h-full w-full scale-125 bg-gradient-to-br from-purple-600 to-pink-600 opacity-40" >
+

Create an account

- - - + + + + + +
- {/* TODO: Fix styling for error text*/} - {errorMessage() &&

{errorMessage()}

} + + {/* TODO: Fix styling for error text */} + {formErrors()?.["formErrors"]?.[0] && ( +

{formErrors()?.["formErrors"]?.[0]}

+ )} +

Already have an acount? diff --git a/src/pages/Test.tsx b/src/pages/Test.tsx new file mode 100644 index 0000000..ba8346b --- /dev/null +++ b/src/pages/Test.tsx @@ -0,0 +1,27 @@ +import { createSignal } from "solid-js"; + +import { APIClient } from "~/libs/client"; + +export default function Test() { + const client = APIClient; + const onClick = async () => { + await client.refresh(); + }; + + const getInfo = async () => { + const { data: info } = await client.me(); + setInfo({ id: info.id, username: info.username }); + }; + + const [info, setInfo] = createSignal<{ id?: string; username?: string }>({}); + return ( +

+
+ + +

ID: {info().id}

+

Username: {info().username}

+
+
+ ); +} diff --git a/src/types.d.ts b/src/types.d.ts index 2f61c39..15a3991 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -7,11 +7,19 @@ export interface Properties { setter: Setter; } -export interface MessageType { +export interface User { + id: string; + username: string; + bot: boolean; + staff: boolean; +} + +export interface Message { id: string; - author: string; content: string; - timestamp: Moment; + timestamp: moment; + author: User; + channelId: string; } export interface CachedUser {