diff --git a/package-lock.json b/package-lock.json index be7d4ff..753e2e1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,9 +10,11 @@ "dependencies": { "@lexical/react": "^0.34.0", "@lexical/rich-text": "^0.34.0", + "@radix-ui/react-avatar": "^1.1.10", "@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-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", @@ -26,6 +28,7 @@ "cmdk": "^1.1.1", "date-fns": "^4.1.0", "emoji-picker-react": "^4.13.2", + "framer-motion": "^12.23.22", "http-status-codes": "^2.3.0", "js-cookie": "^3.0.5", "lexical": "^0.34.0", @@ -1004,6 +1007,12 @@ "node": ">= 10" } }, + "node_modules/@radix-ui/number": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", + "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==", + "license": "MIT" + }, "node_modules/@radix-ui/primitive": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", @@ -1033,6 +1042,33 @@ } } }, + "node_modules/@radix-ui/react-avatar": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.10.tgz", + "integrity": "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-is-hydrated": "0.1.0", + "@radix-ui/react-use-layout-effect": "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", @@ -1451,6 +1487,37 @@ } } }, + "node_modules/@radix-ui/react-scroll-area": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.10.tgz", + "integrity": "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@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-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "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-separator": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz", @@ -1596,6 +1663,24 @@ } } }, + "node_modules/@radix-ui/react-use-is-hydrated": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.0.tgz", + "integrity": "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.5.0" + }, + "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-layout-effect": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", @@ -2583,6 +2668,33 @@ "node": ">= 6" } }, + "node_modules/framer-motion": { + "version": "12.23.22", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.22.tgz", + "integrity": "sha512-ZgGvdxXCw55ZYvhoZChTlG6pUuehecgvEAJz0BHoC5pQKW1EC5xf1Mul1ej5+ai+pVY0pylyFfdl45qnM1/GsA==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.23.21", + "motion-utils": "^12.23.6", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -4059,6 +4171,21 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/motion-dom": { + "version": "12.23.21", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.21.tgz", + "integrity": "sha512-5xDXx/AbhrfgsQmSE7YESMn4Dpo6x5/DTZ4Iyy4xqDvVHWvFVoV+V2Ri2S/ksx+D40wrZ7gPYiMWshkdoqNgNQ==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.23.6" + } + }, + "node_modules/motion-utils": { + "version": "12.23.6", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz", + "integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -4968,6 +5095,15 @@ } } }, + "node_modules/use-sync-external-store": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", + "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", diff --git a/package.json b/package.json index dfa5ce7..b136150 100644 --- a/package.json +++ b/package.json @@ -13,9 +13,11 @@ "dependencies": { "@lexical/react": "^0.34.0", "@lexical/rich-text": "^0.34.0", + "@radix-ui/react-avatar": "^1.1.10", "@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-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", @@ -29,6 +31,7 @@ "cmdk": "^1.1.1", "date-fns": "^4.1.0", "emoji-picker-react": "^4.13.2", + "framer-motion": "^12.23.22", "http-status-codes": "^2.3.0", "js-cookie": "^3.0.5", "lexical": "^0.34.0", diff --git a/src/app/api/user/route.ts b/src/app/api/user/route.ts index accbb9e..3bab295 100644 --- a/src/app/api/user/route.ts +++ b/src/app/api/user/route.ts @@ -84,7 +84,7 @@ export async function GET(req: NextRequest) { { status: StatusCodes.BAD_REQUEST } ); else { - const id = query_id ? query_id : token ? CLIENT_AUTH_TOKENS[token] : null; + const id = query_id ? parseInt(query_id) : token ? CLIENT_AUTH_TOKENS[token] : null; if (!id) return NextResponse.json( diff --git a/src/app/home/page.tsx b/src/app/home/page.tsx new file mode 100644 index 0000000..9b47b86 --- /dev/null +++ b/src/app/home/page.tsx @@ -0,0 +1,68 @@ +"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"; + +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(""); + } + + return ( + + {}} + sendMessage={() => {}} + /> + + ); +} + +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/login/page.tsx b/src/app/login/page.tsx index 546cc95..cb098e0 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -5,16 +5,39 @@ import { Input } from "@/components/ui/input"; import axios from "axios"; import { useState } from "react"; import Cookies from "js-cookie"; -import { redirect } from "next/navigation"; +import { useRouter } from "next/navigation"; +import { Label } from "@/components/ui/label"; +import Link from "next/link"; 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 () => { - const res = await axios.put("/api/user", { username, password }); - Cookies.set("token", (res.data as any).token); - redirect("/"); + 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 ( @@ -22,8 +45,8 @@ export default function Login() {

Login

- Username - setUsername(e.target.value)} /> + Username or email + setUsername(e.target.value)} type="email" /> Password @@ -32,10 +55,28 @@ export default function Login() { onKeyDown={(e) => { if (e.key === "Enter") login(); }} + type="password" /> -
diff --git a/src/app/page.tsx b/src/app/page.tsx index 2aa95be..5e50005 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,22 +1,120 @@ "use client"; -import AppSidebar from "@/components/Sidebar"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { MessageCircle, Users, Shield } from "lucide-react"; +import { motion } from "framer-motion"; +import { useRouter } from "next/navigation"; + +export default function LandingPage() { + const router = useRouter(); -export default function Home() { return ( - - ok - +
+ {/* Header */} +
+ + Voxa + +
+ + +
+
+ + {/* Hero Section */} +
+ + Chat. Connect. Collaborate. + +

+ A modern chat app for teams and friends. Stay connected with real-time + messaging, secure channels, and seamless collaboration. +

+
+ + +
+
+ + {/* Features */} +
+ } + title="Real-Time Chat" + description="Experience instant messaging with typing indicators, read receipts, and more with our self-hostable node system." + /> + } + title="Group Channels" + description="Organize conversations into channels for projects, teams, or friends by hosting your own server." + /> + } + title="Secure & Private" + description="Your data is encrypted and protected, ensuring safe communication." + /> +
+ + {/* Call to Action */} + {/*
+

+ Ready to start chatting? +

+

+ Create your free account today and bring your conversations together. +

+
+ + +
+
*/} + + {/* Footer */} + +
+ ); +} + +function FeatureCard({ + icon, + title, + description, +}: { + icon: React.ReactNode; + title: string; + description: string; +}) { + return ( + + + {icon} +

{title}

+

{description}

+
+
); } diff --git a/src/app/server/[id]/page.tsx b/src/app/server/[id]/page.tsx index 0837937..e370432 100644 --- a/src/app/server/[id]/page.tsx +++ b/src/app/server/[id]/page.tsx @@ -8,7 +8,7 @@ import Message from "@/types/message"; import useUser, { User } from "@/hooks/get-user"; import Cookies from "js-cookie"; import axios from "axios"; -import { StringMap } from "@/types/typeUtils"; +import { NumberMap, StringMap } from "@/types/typeUtils"; import ServerType from "@/types/server"; export default function Server() { @@ -17,7 +17,7 @@ export default function Server() { const wsRef = useRef(null); const [messages, setMessages] = useState([]); const { user, loading } = useUser(); - const [userList, setUserList] = useState>({}); + const [userList, setUserList] = useState>({}); useEffect(() => { if (!ip || !user || loading) return; @@ -83,9 +83,18 @@ export default function Server() { }, [ip, user, loading]); return ( - + m.channel_id === "general") .toReversed()} diff --git a/src/app/signup/page.tsx b/src/app/signup/page.tsx new file mode 100644 index 0000000..71ff4c8 --- /dev/null +++ b/src/app/signup/page.tsx @@ -0,0 +1,150 @@ +"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"; + +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} +
+ )} + + +
+
+ ); +} diff --git a/src/components/MessageBox.tsx b/src/components/MessageBox.tsx index 05e1b42..d89a05f 100644 --- a/src/components/MessageBox.tsx +++ b/src/components/MessageBox.tsx @@ -11,20 +11,32 @@ import { DropdownMenuTrigger, } from "./ui/dropdown-menu"; import { SmilePlusIcon } from "lucide-react"; -import { useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { format, isToday, isYesterday } from "date-fns"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; -import { StringMap } from "@/types/typeUtils"; +import { NumberMap, StringMap } from "@/types/typeUtils"; import { User } from "@/hooks/get-user"; +import axios from "axios"; function MessageContainer({ message, userList, + setUserList, }: { message: Message; - userList: StringMap; + userList: NumberMap; + setUserList: React.Dispatch>>; }) { + useEffect(() => { + axios + .get("/api/user/", { params: { id: message.from } }) + .then((res) => + setUserList((prev) => ({ ...prev, [message.from]: res.data as User })) + ) + .catch(console.error); + }, [message.from, setUserList]); + return (
@@ -117,19 +129,33 @@ function MessageContainer({ export default function MessageBox({ messages, userList, + setUserList, sendMessage, + channelName, }: { messages: Message[]; - userList: StringMap; + userList: NumberMap; + setUserList: React.Dispatch>>; sendMessage: (m: string) => void; + channelName?: string; }) { const [text, setText] = useState(""); return ( -
+
+ {channelName && ( +
+ {channelName} +
+ )}
{messages.map((msg) => ( - + ))}
diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 4c4a870..f1bdcde 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,21 +1,12 @@ "use client"; import { - Sidebar, - SidebarContent, - SidebarHeader, - SidebarMenu, - SidebarMenuButton, - SidebarMenuItem, - SidebarProvider, -} from "./ui/sidebar"; -import { ChevronDown, HashIcon, MicIcon } from "lucide-react"; -import { - DropdownMenuItem, - DropdownMenu, - DropdownMenuContent, - DropdownMenuTrigger, -} from "./ui/dropdown-menu"; + HashIcon, + MessageCircle, + Plus, + Search, + Volume2Icon, +} from "lucide-react"; import Server from "@/types/server"; import { useEffect, useState } from "react"; import Cookies from "js-cookie"; @@ -32,7 +23,11 @@ import { import { Input } from "./ui/input"; import { Label } from "./ui/label"; import { Button } from "./ui/button"; -import Link from "next/link"; +import { Card } from "./ui/card"; +import { Avatar, AvatarFallback } from "./ui/avatar"; +import { ScrollArea } from "./ui/scroll-area"; +import { useRouter } from "next/navigation"; +import Channel from "@/types/channel"; export default function AppSidebar({ server, @@ -43,6 +38,7 @@ export default function AppSidebar({ }>) { const [servers, setServers] = useState<[string, string][]>([]); const [newServer, setNewServer] = useState({ name: "", ip: "" }); + const router = useRouter(); useEffect(() => { const s = Cookies.get("servers")?.split(","); @@ -63,97 +59,162 @@ export default function AppSidebar({ setNewServer({ name: "", ip: "" }); }; - return server ? ( - - - - Add server - - Add a new server to your server list. - - + return ( +
+
+
+ -
-
- - - setNewServer((prev) => ({ ...prev, name: e.target.value })) - } - placeholder="Voxa Server" - /> -
-
- - - setNewServer((prev) => ({ ...prev, ip: e.target.value })) - } - placeholder="192.168.1.5" - /> -
+ {servers.map( + ([ip, name]) => + ip && ( + + ) + )} + + + + + + + + + Add server + + Add a new server to your server list. + + + +
+
+ + + setNewServer((prev) => ({ + ...prev, + name: e.target.value, + })) + } + placeholder="Voxa Server" + /> +
+
+ + + setNewServer((prev) => ({ + ...prev, + ip: e.target.value, + })) + } + placeholder="192.168.1.5" + /> +
+
+ + + + + + + + + +
+
- - - - - - - - - + {/* Right column */} +
+ {server ? ( +
+

{server.name}

+
+ ) : ( +
+ + +
+ )} + +
+ {server ? ( + <> + {server.channels.map((channel) => ( + + ))} + + ) : ( + <> + + + + + )} +
+
+
+
- - - - - - - - - {server.name} - - - - - - {servers.map((s, i) => ( - - {s[1] || s[0]} - - ))} - - - Add Server - - - - - - - - - {server.channels.map((channel) => ( - - {channel.kind === "voice" ? : } - {channel.name} - - ))} - - - {children} - -
- ) : ( - children + {children} +
); } + +function DMItem({ name, status }: { name: string; status: string }) { + return ( + + + {name[0]} + +
+ {name} + {status} +
+
+ ); +} + +function ChannelProp(channel: Channel) { + return ( + + + {channel.name} + + ); +} + +function ChannelIcon({ kind }: { kind: "text" | "voice" }) { + switch (kind) { + case "text": + return ; + case "voice": + return ; + default: + return null; + } +} diff --git a/src/components/ui/avatar.tsx b/src/components/ui/avatar.tsx new file mode 100644 index 0000000..71e428b --- /dev/null +++ b/src/components/ui/avatar.tsx @@ -0,0 +1,53 @@ +"use client" + +import * as React from "react" +import * as AvatarPrimitive from "@radix-ui/react-avatar" + +import { cn } from "@/lib/utils" + +function Avatar({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarImage({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarFallback({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx new file mode 100644 index 0000000..d05bbc6 --- /dev/null +++ b/src/components/ui/card.tsx @@ -0,0 +1,92 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Card({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +} diff --git a/src/components/ui/scroll-area.tsx b/src/components/ui/scroll-area.tsx new file mode 100644 index 0000000..8e4fa13 --- /dev/null +++ b/src/components/ui/scroll-area.tsx @@ -0,0 +1,58 @@ +"use client" + +import * as React from "react" +import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area" + +import { cn } from "@/lib/utils" + +function ScrollArea({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + {children} + + + + + ) +} + +function ScrollBar({ + className, + orientation = "vertical", + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { ScrollArea, ScrollBar } diff --git a/src/types/typeUtils.ts b/src/types/typeUtils.ts index 8523d32..851514e 100644 --- a/src/types/typeUtils.ts +++ b/src/types/typeUtils.ts @@ -2,10 +2,14 @@ export type StringMap = { [key: string]: T; }; +export type NumberMap = { + [key: number]: T; +}; + export function narrow(s: T, defd: T, ...allowed: T[]): T { - if (allowed.includes(s) || s == defd) { - return s; - } else { - return defd; - } -} \ No newline at end of file + if (allowed.includes(s) || s == defd) { + return s; + } else { + return defd; + } +}