From 39b7d58a24e11fb2e010a486dbd28f33611b76f6 Mon Sep 17 00:00:00 2001 From: Leo dev Date: Thu, 2 Oct 2025 10:54:23 +0200 Subject: [PATCH 1/6] Using .env for secrets #4 --- README.md | 13 +++++++++++++ src/app/api/db.ts | 9 +++++---- src/lib/env.ts | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 4 deletions(-) create mode 100644 src/lib/env.ts diff --git a/README.md b/README.md index 49a4bcf..f823d74 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,16 @@ +## Setup +### Requirements +- Node.Js with npm +- Postgres DB + +```python +DB_USER= # Usually related to the OS username but do your own research +DB_HOST=localhost +DB_PASSWORD= # Can be empty +DB_PORT=5432 # Optional +``` + + ## Keywords - Voxa Client: The user-side app which communicates with all below. diff --git a/src/app/api/db.ts b/src/app/api/db.ts index 69d603c..fd45a72 100644 --- a/src/app/api/db.ts +++ b/src/app/api/db.ts @@ -1,11 +1,12 @@ +import { envEnsure, envNumber } from "@/lib/env"; import { Pool } from "pg"; const pool = new Pool({ - user: "klestiselimaj", - host: "localhost", + user: envEnsure("DB_USER"), + host: envEnsure("DB_HOST"), database: "postgres", - password: "", - port: 5432, + password: envEnsure("DB_PASSWORD"), + port: envNumber("DB_PORT") || 5432, }); const createUsersTableQuery = ` diff --git a/src/lib/env.ts b/src/lib/env.ts new file mode 100644 index 0000000..14690ff --- /dev/null +++ b/src/lib/env.ts @@ -0,0 +1,32 @@ +export function env(name: string): string | undefined { + return process.env[name]; +} + +export function envEnsure(name: string): string { + const v = process.env[name]; + + if (v === undefined) { + throw new Error( + `Environment variable '${name}' is required but doesn't exist` + ); + } + + return v; +} + +export function envNumber(name: string): number | undefined { + const v = process.env[name]; + return v ? parseInt(v) : undefined; +} + +export function envNumberEnsure(name: string): number { + const v = process.env[name]; + + if (v === undefined) { + throw new Error( + `Environment variable '${name}' is required but doesn't exist` + ); + } + + return parseInt(v); +} From fe89e38067a910b578900f93cb0e56a42f3f2996 Mon Sep 17 00:00:00 2001 From: Leo dev Date: Thu, 2 Oct 2025 11:04:07 +0200 Subject: [PATCH 2/6] Added a lib for client settings #3 --- src/lib/clientSettings.ts | 28 ++++++++++++++++++++++++++++ src/types/config.ts | 5 +++++ 2 files changed, 33 insertions(+) create mode 100644 src/lib/clientSettings.ts create mode 100644 src/types/config.ts diff --git a/src/lib/clientSettings.ts b/src/lib/clientSettings.ts new file mode 100644 index 0000000..1822546 --- /dev/null +++ b/src/lib/clientSettings.ts @@ -0,0 +1,28 @@ +import { ClientSettings } from "@/types/config"; +import Cookies from "js-cookie"; + +const defaultSettings: ClientSettings = { + theme: "dark", +}; + +export function getClientSettings(): ClientSettings { + const s_cookie = Cookies.get("settings"); + let parsed: Partial = {}; + + if (s_cookie) { + try { + parsed = JSON.parse(s_cookie); + } catch { + parsed = {}; + } + } + + return { + ...defaultSettings, + ...parsed, + }; +} + +export function setClientSettings(value?: ClientSettings) { + Cookies.set("settings", JSON.stringify(value || defaultSettings)); +} diff --git a/src/types/config.ts b/src/types/config.ts new file mode 100644 index 0000000..7c196d0 --- /dev/null +++ b/src/types/config.ts @@ -0,0 +1,5 @@ +export interface ClientSettings { + theme: "light" | "dark"; +} + +export interface AccountSettings {} From d1c0650b4d5281f2ddc7eabe63cae4b482fcd72f Mon Sep 17 00:00:00 2001 From: Leo dev Date: Thu, 2 Oct 2025 11:31:26 +0200 Subject: [PATCH 3/6] Improved login and sign up --- package-lock.json | 31 +++++ package.json | 1 + src/app/login/page.tsx | 82 +------------- src/app/signup/page.tsx | 147 +----------------------- src/components/auth/Auth.tsx | 29 +++++ src/components/auth/CreateAccount.tsx | 156 ++++++++++++++++++++++++++ src/components/auth/Login.tsx | 89 +++++++++++++++ src/components/ui/tabs.tsx | 66 +++++++++++ 8 files changed, 376 insertions(+), 225 deletions(-) create mode 100644 src/components/auth/Auth.tsx create mode 100644 src/components/auth/CreateAccount.tsx create mode 100644 src/components/auth/Login.tsx create mode 100644 src/components/ui/tabs.tsx diff --git a/package-lock.json b/package-lock.json index 753e2e1..74105af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "@types/axios": "^0.9.36", "@types/date-fns": "^2.5.3", @@ -1559,6 +1560,36 @@ } } }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", + "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-tooltip": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", diff --git a/package.json b/package.json index b136150..ceb1207 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "@types/axios": "^0.9.36", "@types/date-fns": "^2.5.3", diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index cb098e0..092325a 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -1,85 +1,7 @@ "use client"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import axios from "axios"; -import { useState } from "react"; -import Cookies from "js-cookie"; -import { useRouter } from "next/navigation"; -import { Label } from "@/components/ui/label"; -import Link from "next/link"; +import Auth from "@/components/auth/Auth"; export default function Login() { - const [username, setUsername] = useState(""); - const [password, setPassword] = useState(""); - const [feedback, setFeedback] = useState< - undefined | { kind: "error" | "info"; message: string } - >(); - - const router = useRouter(); - - const login = async () => { - setFeedback(undefined); - try { - const res = await axios.put("/api/user", { username, password }); - - Cookies.set("token", (res.data as any).token); - setFeedback({ - kind: "info", - message: "Login successful! Redirecting...", - }); - - // small delay to show feedback before redirect - setTimeout(() => router.push("/home"), 800); - } catch (err: any) { - setFeedback({ - kind: "error", - message: - err.response?.data?.message || "Login failed. Please try again.", - }); - } - }; - - return ( -
-
-

Login

- - Username or email - setUsername(e.target.value)} type="email" /> - - - Password - setPassword(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") login(); - }} - type="password" - /> - - - {/* Feedback message */} - {feedback && ( -
- {feedback.message} -
- )} - - - - -
-
- ); + return ; } diff --git a/src/app/signup/page.tsx b/src/app/signup/page.tsx index 71ff4c8..4a6a477 100644 --- a/src/app/signup/page.tsx +++ b/src/app/signup/page.tsx @@ -1,150 +1,7 @@ "use client"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import axios from "axios"; -import { useState } from "react"; -import Cookies from "js-cookie"; -import { useRouter } from "next/navigation"; -import Link from "next/link"; +import Auth from "@/components/auth/Auth"; export default function SignUp() { - const [username, setUsername] = useState(""); - const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); - const [confirmPassword, setConfirmPassword] = useState(""); - const [acceptTerms, setAcceptTerms] = useState(false); - const [loading, setLoading] = useState(false); - const [feedback, setFeedback] = useState< - undefined | { kind: "error" | "info"; message: string } - >(); - - const router = useRouter(); - - const validateInput = (): - | { kind: "error" | "info"; message: string } - | undefined => { - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - const usernameRegex = /^[a-zA-Z0-9_]{3,20}$/; - - if (!emailRegex.test(email)) { - return { kind: "error", message: "Invalid email format." }; - } - if (!usernameRegex.test(username)) { - return { - kind: "error", - message: - "Username must be 3–20 characters and only letters, numbers, or underscores.", - }; - } - if (password.length < 6) { - return { - kind: "error", - message: "Password must be at least 6 characters long.", - }; - } - if (password !== confirmPassword) { - return { kind: "error", message: "Passwords do not match." }; - } - if (!acceptTerms) { - return { - kind: "error", - message: "You must accept the terms to sign up.", - }; - } - return; - }; - - const signup = async () => { - setFeedback(undefined); - const validationError = validateInput(); - if (validationError) { - setFeedback(validationError); - return; - } - - try { - setLoading(true); - const res = await axios.post("/api/user", { username, password, email }); - Cookies.set("token", (res.data as any).token); - - setFeedback({ kind: "info", message: "Account created! Redirecting..." }); - setTimeout(() => router.push("/home"), 1000); - } catch (err: any) { - setFeedback({ - kind: "error", - message: - err.response?.data?.message || - "Signup failed. Please try again later.", - }); - } finally { - setLoading(false); - } - }; - - return ( -
-
-

Create an Account

- - Email - setEmail(e.target.value)} - placeholder="john@example.com" - type="email" - /> - - - Username - setUsername(e.target.value)} - placeholder="john_doe" - /> - - - Password - setPassword(e.target.value)} - type="password" - /> - - - Confirm Password - setConfirmPassword(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") signup(); - }} - type="password" - /> - - - - - {/* Feedback message */} - {feedback && ( -
- {feedback.message} -
- )} - - -
-
- ); + return ; } diff --git a/src/components/auth/Auth.tsx b/src/components/auth/Auth.tsx new file mode 100644 index 0000000..b8e60bc --- /dev/null +++ b/src/components/auth/Auth.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { Card } from "../ui/card"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"; +import CreateAccount from "./CreateAccount"; +import Login from "./Login"; + +export default function Auth({ kind }: { kind: "login" | "create" }) { + return ( +
+ + + Login + Create Account + + + + + + + + + + + + +
+ ); +} diff --git a/src/components/auth/CreateAccount.tsx b/src/components/auth/CreateAccount.tsx new file mode 100644 index 0000000..223b69e --- /dev/null +++ b/src/components/auth/CreateAccount.tsx @@ -0,0 +1,156 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import axios from "axios"; +import { useState } from "react"; +import Cookies from "js-cookie"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { CardContent, CardHeader, CardTitle } from "../ui/card"; +import { Label } from "../ui/label"; + +export default function CreateAccount() { + const [username, setUsername] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [acceptTerms, setAcceptTerms] = useState(false); + const [loading, setLoading] = useState(false); + const [feedback, setFeedback] = useState< + undefined | { kind: "error" | "info"; message: string } + >(); + + const router = useRouter(); + + const validateInput = (): + | { kind: "error" | "info"; message: string } + | undefined => { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + const usernameRegex = /^[a-zA-Z0-9_]{3,20}$/; + + if (!emailRegex.test(email)) { + return { kind: "error", message: "Invalid email format." }; + } + if (!usernameRegex.test(username)) { + return { + kind: "error", + message: + "Username must be 3–20 characters and only letters, numbers, or underscores.", + }; + } + if (password.length < 6) { + return { + kind: "error", + message: "Password must be at least 6 characters long.", + }; + } + if (password !== confirmPassword) { + return { kind: "error", message: "Passwords do not match." }; + } + if (!acceptTerms) { + return { + kind: "error", + message: "You must accept the terms to sign up.", + }; + } + return; + }; + + const signup = async () => { + setFeedback(undefined); + const validationError = validateInput(); + if (validationError) { + setFeedback(validationError); + return; + } + + try { + setLoading(true); + const res = await axios.post("/api/user", { username, password, email }); + Cookies.set("token", (res.data as any).token); + + setFeedback({ kind: "info", message: "Account created! Redirecting..." }); + setTimeout(() => router.push("/home"), 1000); + } catch (err: any) { + setFeedback({ + kind: "error", + message: + err.response?.data?.message || + "Signup failed. Please try again later.", + }); + } finally { + setLoading(false); + } + }; + + return ( + <> + + Create an Account + + + + Email + setEmail(e.target.value)} + placeholder="john@example.com" + type="email" + /> + + + Username + setUsername(e.target.value)} + placeholder="john_doe" + /> + + + Password + setPassword(e.target.value)} + type="password" + /> + + + Confirm Password + setConfirmPassword(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") signup(); + }} + type="password" + /> + + + + + {feedback && ( +
+ {feedback.message} +
+ )} + + +
+ + ); +} diff --git a/src/components/auth/Login.tsx b/src/components/auth/Login.tsx new file mode 100644 index 0000000..3adc91e --- /dev/null +++ b/src/components/auth/Login.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import axios from "axios"; +import { useState } from "react"; +import Cookies from "js-cookie"; +import { useRouter } from "next/navigation"; +import { Label } from "@/components/ui/label"; +import Link from "next/link"; +import { + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "../ui/card"; + +export default function Login() { + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [feedback, setFeedback] = useState< + undefined | { kind: "error" | "info"; message: string } + >(); + + const router = useRouter(); + + const login = async () => { + setFeedback(undefined); + try { + const res = await axios.put("/api/user", { username, password }); + + Cookies.set("token", (res.data as any).token); + setFeedback({ + kind: "info", + message: "Login successful! Redirecting...", + }); + + // small delay to show feedback before redirect + setTimeout(() => router.push("/home"), 800); + } catch (err: any) { + setFeedback({ + kind: "error", + message: + err.response?.data?.message || "Login failed. Please try again.", + }); + } + }; + + return ( + <> + + Login + + + + Username or email + setUsername(e.target.value)} type="email" /> + + + Password + setPassword(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") login(); + }} + type="password" + /> + + + {feedback && ( +
+ {feedback.message} +
+ )} + + +
+ + ); +} diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx new file mode 100644 index 0000000..497ba5e --- /dev/null +++ b/src/components/ui/tabs.tsx @@ -0,0 +1,66 @@ +"use client" + +import * as React from "react" +import * as TabsPrimitive from "@radix-ui/react-tabs" + +import { cn } from "@/lib/utils" + +function Tabs({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function TabsList({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function TabsTrigger({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function TabsContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Tabs, TabsList, TabsTrigger, TabsContent } From 99a1b3b2642c37cefa7a9153d18666be82b15d7f Mon Sep 17 00:00:00 2001 From: Leo dev Date: Thu, 2 Oct 2025 12:10:59 +0200 Subject: [PATCH 4/6] Simple settings page #3 --- package-lock.json | 90 ++++++++++++++++ package.json | 2 + src/app/settings/page.tsx | 54 ++++++++++ src/app/settings/settings.tsx | 91 ++++++++++++++++ src/components/ui/checkbox.tsx | 32 ++++++ src/components/ui/select.tsx | 185 +++++++++++++++++++++++++++++++++ src/hooks/use-settings.ts | 18 ++++ src/lib/clientSettings.ts | 4 +- src/types/config.ts | 5 - src/types/settings.ts | 15 +++ 10 files changed, 489 insertions(+), 7 deletions(-) create mode 100644 src/app/settings/page.tsx create mode 100644 src/app/settings/settings.tsx create mode 100644 src/components/ui/checkbox.tsx create mode 100644 src/components/ui/select.tsx create mode 100644 src/hooks/use-settings.ts delete mode 100644 src/types/config.ts create mode 100644 src/types/settings.ts diff --git a/package-lock.json b/package-lock.json index 74105af..ba29dfa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,10 +11,12 @@ "@lexical/react": "^0.34.0", "@lexical/rich-text": "^0.34.0", "@radix-ui/react-avatar": "^1.1.10", + "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-scroll-area": "^1.2.10", + "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tabs": "^1.1.13", @@ -1070,6 +1072,36 @@ } } }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz", + "integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-collection": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", @@ -1519,6 +1551,49 @@ } } }, + "node_modules/@radix-ui/react-select": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz", + "integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-separator": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz", @@ -1727,6 +1802,21 @@ } } }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", + "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-rect": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", diff --git a/package.json b/package.json index ceb1207..bfe1b65 100644 --- a/package.json +++ b/package.json @@ -14,10 +14,12 @@ "@lexical/react": "^0.34.0", "@lexical/rich-text": "^0.34.0", "@radix-ui/react-avatar": "^1.1.10", + "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-scroll-area": "^1.2.10", + "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tabs": "^1.1.13", diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx new file mode 100644 index 0000000..527ac4e --- /dev/null +++ b/src/app/settings/page.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { useClientSettings } from "@/hooks/use-settings"; +import { setClientSettings } from "@/lib/clientSettings"; +import Settings from "./settings"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Card } from "@/components/ui/card"; +import { CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { ACCOUNT_SCHEMA, CLIENT_SCHEMA } from "@/types/settings"; + +export default function SettingsPage() { + const [settings, setSettings] = useClientSettings(); + + return ( + + + Client Settings + Account Settings + + + + + + + + + + + + + + + + + + + ); +} diff --git a/src/app/settings/settings.tsx b/src/app/settings/settings.tsx new file mode 100644 index 0000000..2f3d938 --- /dev/null +++ b/src/app/settings/settings.tsx @@ -0,0 +1,91 @@ +"use client"; + +import { Dispatch, SetStateAction } from "react"; +import { Input } from "@/components/ui/input"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { StringMap } from "@/types/typeUtils"; + +export type SettingsSchema = StringMap< + "boolean" | "string" | "number" | string[] +>; + +export default function Settings({ + settings, + setSettings, + schema, +}: { + settings: T; + setSettings: Dispatch>; + schema: SettingsSchema; +}) { + const handleChange = (key: string, value: any) => { + setSettings((prev) => ({ ...prev, [key]: value })); + }; + + const renderInput = (key: string, type: any) => { + if (Array.isArray(type)) { + // Dropdown + return ( + + ); + } else if (type === "boolean") { + // Checkbox + return ( + handleChange(key, val)} + /> + ); + } else if (type === "string") { + // Text input + return ( + handleChange(key, e.target.value)} + /> + ); + } else if (type === "number") { + // Number input + return ( + handleChange(key, Number(e.target.value))} + /> + ); + } + }; + + return ( + <> + {Object.entries(schema).map(([key, type]) => ( +
+ + {renderInput(key, type)} +
+ ))} + + ); +} diff --git a/src/components/ui/checkbox.tsx b/src/components/ui/checkbox.tsx new file mode 100644 index 0000000..fa0e4b5 --- /dev/null +++ b/src/components/ui/checkbox.tsx @@ -0,0 +1,32 @@ +"use client" + +import * as React from "react" +import * as CheckboxPrimitive from "@radix-ui/react-checkbox" +import { CheckIcon } from "lucide-react" + +import { cn } from "@/lib/utils" + +function Checkbox({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + + ) +} + +export { Checkbox } diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx new file mode 100644 index 0000000..dcbbc0c --- /dev/null +++ b/src/components/ui/select.tsx @@ -0,0 +1,185 @@ +"use client" + +import * as React from "react" +import * as SelectPrimitive from "@radix-ui/react-select" +import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react" + +import { cn } from "@/lib/utils" + +function Select({ + ...props +}: React.ComponentProps) { + return +} + +function SelectGroup({ + ...props +}: React.ComponentProps) { + return +} + +function SelectValue({ + ...props +}: React.ComponentProps) { + return +} + +function SelectTrigger({ + className, + size = "default", + children, + ...props +}: React.ComponentProps & { + size?: "sm" | "default" +}) { + return ( + + {children} + + + + + ) +} + +function SelectContent({ + className, + children, + position = "popper", + ...props +}: React.ComponentProps) { + return ( + + + + + {children} + + + + + ) +} + +function SelectLabel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SelectItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function SelectSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SelectScrollUpButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function SelectScrollDownButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectScrollDownButton, + SelectScrollUpButton, + SelectSeparator, + SelectTrigger, + SelectValue, +} diff --git a/src/hooks/use-settings.ts b/src/hooks/use-settings.ts new file mode 100644 index 0000000..a4e51f5 --- /dev/null +++ b/src/hooks/use-settings.ts @@ -0,0 +1,18 @@ +"use client"; + +import { defaultSettings, getClientSettings } from "@/lib/clientSettings"; +import { ClientSettings } from "@/types/settings"; +import { Dispatch, SetStateAction, useEffect, useState } from "react"; + +export function useClientSettings(): [ + ClientSettings, + Dispatch> +] { + const [settings, setSettings] = useState(defaultSettings); + + useEffect(() => { + setSettings(getClientSettings()); + }, []); + + return [settings, setSettings]; +} diff --git a/src/lib/clientSettings.ts b/src/lib/clientSettings.ts index 1822546..4a7e385 100644 --- a/src/lib/clientSettings.ts +++ b/src/lib/clientSettings.ts @@ -1,7 +1,7 @@ -import { ClientSettings } from "@/types/config"; +import { ClientSettings } from "@/types/settings"; import Cookies from "js-cookie"; -const defaultSettings: ClientSettings = { +export const defaultSettings: ClientSettings = { theme: "dark", }; diff --git a/src/types/config.ts b/src/types/config.ts deleted file mode 100644 index 7c196d0..0000000 --- a/src/types/config.ts +++ /dev/null @@ -1,5 +0,0 @@ -export interface ClientSettings { - theme: "light" | "dark"; -} - -export interface AccountSettings {} diff --git a/src/types/settings.ts b/src/types/settings.ts new file mode 100644 index 0000000..22a5b15 --- /dev/null +++ b/src/types/settings.ts @@ -0,0 +1,15 @@ +import { SettingsSchema } from "@/app/settings/settings"; + +export interface ClientSettings { + theme: "light" | "dark"; +} + +export interface AccountSettings {} + +export const CLIENT_SCHEMA: SettingsSchema = { + theme: ["light", "dark"], +}; + +export const ACCOUNT_SCHEMA: SettingsSchema = { + node_ip: "string", +}; From 0734dde01e0084cf162d1708caac0c615cec2a0d Mon Sep 17 00:00:00 2001 From: Leo dev Date: Thu, 2 Oct 2025 12:39:43 +0200 Subject: [PATCH 5/6] Regex settings #3 --- src/app/settings/page.tsx | 19 +++------ src/app/settings/settings.tsx | 79 +++++++++++++++++++++++++++++++---- src/hooks/use-settings.ts | 13 +++++- src/types/settings.ts | 11 +++-- 4 files changed, 97 insertions(+), 25 deletions(-) diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index 527ac4e..bd81341 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -1,16 +1,16 @@ "use client"; -import { useClientSettings } from "@/hooks/use-settings"; +import { useAccountSettings, useClientSettings } from "@/hooks/use-settings"; import { setClientSettings } from "@/lib/clientSettings"; import Settings from "./settings"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Card } from "@/components/ui/card"; import { CardContent } from "@/components/ui/card"; -import { Button } from "@/components/ui/button"; import { ACCOUNT_SCHEMA, CLIENT_SCHEMA } from "@/types/settings"; export default function SettingsPage() { const [settings, setSettings] = useClientSettings(); + const [accountSettings, setAccountSettings] = useAccountSettings(); return ( @@ -25,13 +25,8 @@ export default function SettingsPage() { settings={settings} setSettings={setSettings} schema={CLIENT_SCHEMA} + onSave={() => setClientSettings(settings)} /> - @@ -39,13 +34,11 @@ export default function SettingsPage() { {}} /> - diff --git a/src/app/settings/settings.tsx b/src/app/settings/settings.tsx index 2f3d938..01f2c59 100644 --- a/src/app/settings/settings.tsx +++ b/src/app/settings/settings.tsx @@ -1,6 +1,4 @@ -"use client"; - -import { Dispatch, SetStateAction } from "react"; +import { Dispatch, SetStateAction, useState, useRef } from "react"; import { Input } from "@/components/ui/input"; import { Checkbox } from "@/components/ui/checkbox"; import { @@ -11,27 +9,65 @@ import { SelectValue, } from "@/components/ui/select"; import { StringMap } from "@/types/typeUtils"; +import { Button } from "@/components/ui/button"; + +export class RegexSetting { + placeholder: string; + pattern: RegExp; + + constructor(placeholder: string, pattern: RegExp) { + this.placeholder = placeholder; + this.pattern = pattern; + } +} export type SettingsSchema = StringMap< - "boolean" | "string" | "number" | string[] + "boolean" | "string" | "number" | string[] | RegexSetting >; export default function Settings({ settings, setSettings, schema, + onSave, }: { settings: T; setSettings: Dispatch>; schema: SettingsSchema; + onSave: () => void; }) { + const [feedback, setFeedback] = useState< + undefined | { kind: "error" | "info"; message: string } + >(); + + // Ref to store debounce timer + const debounceRef = useRef(undefined); + const handleChange = (key: string, value: any) => { setSettings((prev) => ({ ...prev, [key]: value })); }; + const handleRegexChange = ( + key: string, + regexSetting: RegexSetting, + value: string + ) => { + handleChange(key, value); + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => { + if (!regexSetting.pattern.test(value)) { + setFeedback({ + kind: "error", + message: `Value does not match the pattern!`, + }); + } else { + setFeedback(undefined); + } + }, 1500); + }; + const renderInput = (key: string, type: any) => { if (Array.isArray(type)) { - // Dropdown return ( ); } else if (type === "boolean") { - // Checkbox return ( ({ /> ); } else if (type === "string") { - // Text input return ( ({ /> ); } else if (type === "number") { - // Number input return ( ({ onChange={(e) => handleChange(key, Number(e.target.value))} /> ); + } else if (type instanceof RegexSetting) { + return ( + handleRegexChange(key, type, e.target.value)} + /> + ); } }; @@ -86,6 +128,27 @@ export default function Settings({ {renderInput(key, type)} ))} + + {feedback && ( +
+ {feedback.message} +
+ )} + + ); } diff --git a/src/hooks/use-settings.ts b/src/hooks/use-settings.ts index a4e51f5..eb17b47 100644 --- a/src/hooks/use-settings.ts +++ b/src/hooks/use-settings.ts @@ -1,7 +1,7 @@ "use client"; import { defaultSettings, getClientSettings } from "@/lib/clientSettings"; -import { ClientSettings } from "@/types/settings"; +import { AccountSettings, ClientSettings } from "@/types/settings"; import { Dispatch, SetStateAction, useEffect, useState } from "react"; export function useClientSettings(): [ @@ -16,3 +16,14 @@ export function useClientSettings(): [ return [settings, setSettings]; } + +export function useAccountSettings(): [ + AccountSettings, + Dispatch> +] { + const [settings, setSettings] = useState({ + node_ip: "node0.voxa.org", + }); + + return [settings, setSettings]; +} diff --git a/src/types/settings.ts b/src/types/settings.ts index 22a5b15..973f305 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -1,15 +1,20 @@ -import { SettingsSchema } from "@/app/settings/settings"; +import { RegexSetting, SettingsSchema } from "@/app/settings/settings"; export interface ClientSettings { theme: "light" | "dark"; } -export interface AccountSettings {} +export interface AccountSettings { + node_ip: string; +} export const CLIENT_SCHEMA: SettingsSchema = { theme: ["light", "dark"], }; export const ACCOUNT_SCHEMA: SettingsSchema = { - node_ip: "string", + node_ip: new RegexSetting( + "node2.voxa.org", + /^(?:(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}|(?:\d{1,3}\.){3}\d{1,3})$/ + ), }; From 10f6f3b736edce53fd0c9f78978c73b798d56840 Mon Sep 17 00:00:00 2001 From: Leo dev Date: Thu, 2 Oct 2025 13:14:30 +0200 Subject: [PATCH 6/6] Better ui and slight change for settings #3 --- src/app/home/page.tsx | 53 ++---------------------- src/app/layout.tsx | 10 ++++- src/app/server/[id]/page.tsx | 1 + src/app/settings/page.tsx | 76 +++++++++++++++++++++-------------- src/components/MessageBox.tsx | 14 ++++++- src/components/Sidebar.tsx | 19 ++++++++- src/lib/clientSettings.ts | 11 +++-- 7 files changed, 96 insertions(+), 88 deletions(-) diff --git a/src/app/home/page.tsx b/src/app/home/page.tsx index 9b47b86..3554c13 100644 --- a/src/app/home/page.tsx +++ b/src/app/home/page.tsx @@ -1,28 +1,14 @@ "use client"; -import { Button } from "@/components/ui/button"; -import { Avatar, AvatarFallback } from "@/components/ui/avatar"; -import { Card } from "@/components/ui/card"; -import { useState } from "react"; import AppSidebar from "@/components/Sidebar"; import MessageBox from "@/components/MessageBox"; +import useUser from "@/hooks/get-user"; export default function HomePage() { - const [messages, setMessages] = useState([ - { id: 1, user: "Alice", text: "Hey, how are you?" }, - { id: 2, user: "You", text: "Doing good, just working on the chat app!" }, - ]); - - const [input, setInput] = useState(""); - - function sendMessage() { - if (!input.trim()) return; - setMessages([...messages, { id: Date.now(), user: "You", text: input }]); - setInput(""); - } + const { user, loading } = useUser(); return ( - + ); } - -function ServerButton({ - label, - icon, -}: { - label: string; - icon: React.ReactNode; -}) { - return ( - - ); -} - -function DMItem({ name, status }: { name: string; status: string }) { - return ( - - - {name[0]} - -
- {name} - {status} -
-
- ); -} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 11234a9..8642836 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,8 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; +import { cookies } from "next/headers"; +import { parseClientSettings } from "@/lib/clientSettings"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -17,15 +19,19 @@ export const metadata: Metadata = { description: "Generated by create next app", }; -export default function RootLayout({ +export default async function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { + const cookieStore = await cookies(); + + const settings = parseClientSettings(cookieStore.get("settings")?.value); + return ( {children} diff --git a/src/app/server/[id]/page.tsx b/src/app/server/[id]/page.tsx index e370432..0b7a978 100644 --- a/src/app/server/[id]/page.tsx +++ b/src/app/server/[id]/page.tsx @@ -84,6 +84,7 @@ export default function Server() { return ( - - Client Settings - Account Settings - - - - - setClientSettings(settings)} - /> - - - - - - - {}} - /> - - - -
+
+ + + + Client Settings + Account Settings + + + + + { + setClientSettings(settings); + router.refresh(); + }} + /> + + + + + + + {}} + /> + + + + +
); } diff --git a/src/components/MessageBox.tsx b/src/components/MessageBox.tsx index d89a05f..264b7c2 100644 --- a/src/components/MessageBox.tsx +++ b/src/components/MessageBox.tsx @@ -18,6 +18,7 @@ import remarkGfm from "remark-gfm"; import { NumberMap, StringMap } from "@/types/typeUtils"; import { User } from "@/hooks/get-user"; import axios from "axios"; +import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar"; function MessageContainer({ message, @@ -40,7 +41,18 @@ function MessageContainer({ return (
-
+ {/*
*/} + + + + {userList[message.from] + ? userList[message.from].username + : message.from} + +
{userList[message.from] diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index f1bdcde..86e5a29 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -5,6 +5,7 @@ import { MessageCircle, Plus, Search, + SettingsIcon, Volume2Icon, } from "lucide-react"; import Server from "@/types/server"; @@ -24,16 +25,21 @@ import { Input } from "./ui/input"; import { Label } from "./ui/label"; import { Button } from "./ui/button"; import { Card } from "./ui/card"; -import { Avatar, AvatarFallback } from "./ui/avatar"; +import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar"; import { ScrollArea } from "./ui/scroll-area"; import { useRouter } from "next/navigation"; import Channel from "@/types/channel"; +import Link from "next/link"; +import { NumberMap } from "@/types/typeUtils"; +import { User } from "@/hooks/get-user"; export default function AppSidebar({ + user, server, children, }: Readonly<{ server?: Server; + user: User | null; children: React.ReactNode; }>) { const [servers, setServers] = useState<[string, string][]>([]); @@ -143,6 +149,12 @@ export default function AppSidebar({ + +
+ + + +
{/* Right column */} @@ -177,6 +189,10 @@ export default function AppSidebar({ )}
+ +
+ +
@@ -189,6 +205,7 @@ function DMItem({ name, status }: { name: string; status: string }) { return ( + {name[0]}
diff --git a/src/lib/clientSettings.ts b/src/lib/clientSettings.ts index 4a7e385..484dcdf 100644 --- a/src/lib/clientSettings.ts +++ b/src/lib/clientSettings.ts @@ -5,13 +5,12 @@ export const defaultSettings: ClientSettings = { theme: "dark", }; -export function getClientSettings(): ClientSettings { - const s_cookie = Cookies.get("settings"); +export function parseClientSettings(s?: string): ClientSettings { let parsed: Partial = {}; - if (s_cookie) { + if (s) { try { - parsed = JSON.parse(s_cookie); + parsed = JSON.parse(s); } catch { parsed = {}; } @@ -23,6 +22,10 @@ export function getClientSettings(): ClientSettings { }; } +export function getClientSettings(): ClientSettings { + return parseClientSettings(Cookies.get("settings")); +} + export function setClientSettings(value?: ClientSettings) { Cookies.set("settings", JSON.stringify(value || defaultSettings)); }