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/package-lock.json b/package-lock.json index 753e2e1..ba29dfa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,12 +11,15 @@ "@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", "@radix-ui/react-tooltip": "^1.2.8", "@types/axios": "^0.9.36", "@types/date-fns": "^2.5.3", @@ -1069,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", @@ -1518,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", @@ -1559,6 +1635,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", @@ -1696,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 b136150..bfe1b65 100644 --- a/package.json +++ b/package.json @@ -14,12 +14,15 @@ "@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", "@radix-ui/react-tooltip": "^1.2.8", "@types/axios": "^0.9.36", "@types/date-fns": "^2.5.3", 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/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/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/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); + router.refresh(); + }} + /> + + + + + + + {}} + /> + + + + + + ); +} diff --git a/src/app/settings/settings.tsx b/src/app/settings/settings.tsx new file mode 100644 index 0000000..01f2c59 --- /dev/null +++ b/src/app/settings/settings.tsx @@ -0,0 +1,154 @@ +import { Dispatch, SetStateAction, useState, useRef } 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"; +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[] | 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)) { + return ( + + ); + } else if (type === "boolean") { + return ( + handleChange(key, val)} + /> + ); + } else if (type === "string") { + return ( + handleChange(key, e.target.value)} + /> + ); + } else if (type === "number") { + return ( + handleChange(key, Number(e.target.value))} + /> + ); + } else if (type instanceof RegexSetting) { + return ( + handleRegexChange(key, type, e.target.value)} + /> + ); + } + }; + + return ( + <> + {Object.entries(schema).map(([key, type]) => ( +
+ + {renderInput(key, type)} +
+ ))} + + {feedback && ( +
+ {feedback.message} +
+ )} + + + + ); +} 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/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/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/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/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 } diff --git a/src/hooks/use-settings.ts b/src/hooks/use-settings.ts new file mode 100644 index 0000000..eb17b47 --- /dev/null +++ b/src/hooks/use-settings.ts @@ -0,0 +1,29 @@ +"use client"; + +import { defaultSettings, getClientSettings } from "@/lib/clientSettings"; +import { AccountSettings, 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]; +} + +export function useAccountSettings(): [ + AccountSettings, + Dispatch> +] { + const [settings, setSettings] = useState({ + node_ip: "node0.voxa.org", + }); + + return [settings, setSettings]; +} diff --git a/src/lib/clientSettings.ts b/src/lib/clientSettings.ts new file mode 100644 index 0000000..484dcdf --- /dev/null +++ b/src/lib/clientSettings.ts @@ -0,0 +1,31 @@ +import { ClientSettings } from "@/types/settings"; +import Cookies from "js-cookie"; + +export const defaultSettings: ClientSettings = { + theme: "dark", +}; + +export function parseClientSettings(s?: string): ClientSettings { + let parsed: Partial = {}; + + if (s) { + try { + parsed = JSON.parse(s); + } catch { + parsed = {}; + } + } + + return { + ...defaultSettings, + ...parsed, + }; +} + +export function getClientSettings(): ClientSettings { + return parseClientSettings(Cookies.get("settings")); +} + +export function setClientSettings(value?: ClientSettings) { + Cookies.set("settings", JSON.stringify(value || defaultSettings)); +} 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); +} diff --git a/src/types/settings.ts b/src/types/settings.ts new file mode 100644 index 0000000..973f305 --- /dev/null +++ b/src/types/settings.ts @@ -0,0 +1,20 @@ +import { RegexSetting, SettingsSchema } from "@/app/settings/settings"; + +export interface ClientSettings { + theme: "light" | "dark"; +} + +export interface AccountSettings { + node_ip: string; +} + +export const CLIENT_SCHEMA: SettingsSchema = { + theme: ["light", "dark"], +}; + +export const ACCOUNT_SCHEMA: SettingsSchema = { + node_ip: new RegexSetting( + "node2.voxa.org", + /^(?:(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}|(?:\d{1,3}\.){3}\d{1,3})$/ + ), +};