diff --git a/app/app.css b/app/app.css
index 99345d8..9e56f42 100644
--- a/app/app.css
+++ b/app/app.css
@@ -1,15 +1,146 @@
@import "tailwindcss";
+@import "tw-animate-css";
+
+@custom-variant dark (&:is(.dark *));
@theme {
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif,
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
}
-html,
-body {
- @apply bg-white dark:bg-gray-950;
+@theme inline {
+ --radius-sm: calc(var(--radius) - 4px);
+ --radius-md: calc(var(--radius) - 2px);
+ --radius-lg: var(--radius);
+ --radius-xl: calc(var(--radius) + 4px);
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+ --color-card: var(--card);
+ --color-card-foreground: var(--card-foreground);
+ --color-popover: var(--popover);
+ --color-popover-foreground: var(--popover-foreground);
+ --color-primary: var(--primary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-secondary: var(--secondary);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-muted: var(--muted);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-accent: var(--accent);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-destructive: var(--destructive);
+ --color-border: var(--border);
+ --color-input: var(--input);
+ --color-ring: var(--ring);
+ --color-chart-1: var(--chart-1);
+ --color-chart-2: var(--chart-2);
+ --color-chart-3: var(--chart-3);
+ --color-chart-4: var(--chart-4);
+ --color-chart-5: var(--chart-5);
+ --color-sidebar: var(--sidebar);
+ --color-sidebar-foreground: var(--sidebar-foreground);
+ --color-sidebar-primary: var(--sidebar-primary);
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+ --color-sidebar-accent: var(--sidebar-accent);
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+ --color-sidebar-border: var(--sidebar-border);
+ --color-sidebar-ring: var(--sidebar-ring);
+}
- @media (prefers-color-scheme: dark) {
- color-scheme: dark;
+:root {
+ --radius: 0.625rem;
+ --background: oklch(1 0 0);
+ --foreground: oklch(0.145 0 0);
+ --card: oklch(1 0 0);
+ --card-foreground: oklch(0.145 0 0);
+ --popover: oklch(1 0 0);
+ --popover-foreground: oklch(0.145 0 0);
+ --primary: oklch(0.205 0 0);
+ --primary-foreground: oklch(0.985 0 0);
+ --secondary: oklch(0.97 0 0);
+ --secondary-foreground: oklch(0.205 0 0);
+ --muted: oklch(0.97 0 0);
+ --muted-foreground: oklch(0.556 0 0);
+ --accent: oklch(0.97 0 0);
+ --accent-foreground: oklch(0.205 0 0);
+ --destructive: oklch(0.577 0.245 27.325);
+ --border: oklch(0.922 0 0);
+ --input: oklch(0.922 0 0);
+ --ring: oklch(0.708 0 0);
+ --chart-1: oklch(0.646 0.222 41.116);
+ --chart-2: oklch(0.6 0.118 184.704);
+ --chart-3: oklch(0.398 0.07 227.392);
+ --chart-4: oklch(0.828 0.189 84.429);
+ --chart-5: oklch(0.769 0.188 70.08);
+ --sidebar: oklch(0.985 0 0);
+ --sidebar-foreground: oklch(0.145 0 0);
+ --sidebar-primary: oklch(0.205 0 0);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.97 0 0);
+ --sidebar-accent-foreground: oklch(0.205 0 0);
+ --sidebar-border: oklch(0.922 0 0);
+ --sidebar-ring: oklch(0.708 0 0);
+}
+
+.dark {
+ --background: oklch(0.145 0 0);
+ --foreground: oklch(0.985 0 0);
+ --card: oklch(0.205 0 0);
+ --card-foreground: oklch(0.985 0 0);
+ --popover: oklch(0.205 0 0);
+ --popover-foreground: oklch(0.985 0 0);
+ --primary: oklch(0.922 0 0);
+ --primary-foreground: oklch(0.205 0 0);
+ --secondary: oklch(0.269 0 0);
+ --secondary-foreground: oklch(0.985 0 0);
+ --muted: oklch(0.269 0 0);
+ --muted-foreground: oklch(0.708 0 0);
+ --accent: oklch(0.269 0 0);
+ --accent-foreground: oklch(0.985 0 0);
+ --destructive: oklch(0.704 0.191 22.216);
+ --border: oklch(1 0 0 / 10%);
+ --input: oklch(1 0 0 / 15%);
+ --ring: oklch(0.556 0 0);
+ --chart-1: oklch(0.488 0.243 264.376);
+ --chart-2: oklch(0.696 0.17 162.48);
+ --chart-3: oklch(0.769 0.188 70.08);
+ --chart-4: oklch(0.627 0.265 303.9);
+ --chart-5: oklch(0.645 0.246 16.439);
+ --sidebar: oklch(0.205 0 0);
+ --sidebar-foreground: oklch(0.985 0 0);
+ --sidebar-primary: oklch(0.488 0.243 264.376);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.269 0 0);
+ --sidebar-accent-foreground: oklch(0.985 0 0);
+ --sidebar-border: oklch(1 0 0 / 10%);
+ --sidebar-ring: oklch(0.556 0 0);
+}
+
+@layer base {
+ * {
+ @apply border-border outline-ring/50;
+ }
+
+ body {
+ @apply bg-background text-foreground;
+ }
+
+ button,
+ [role="button"] {
+ cursor: pointer;
}
}
+
+@layer utilities {
+ /* Hide scrollbar for Chrome, Safari and Opera */
+ .no-scrollbar::-webkit-scrollbar {
+ display: none;
+ }
+
+ /* Hide scrollbar for IE, Edge and Firefox */
+ .no-scrollbar {
+ /* IE and Edge */
+ -ms-overflow-style: none;
+ /* Firefox */
+ scrollbar-width: none;
+ }
+}
\ No newline at end of file
diff --git a/app/components/app-layout.tsx b/app/components/app-layout.tsx
new file mode 100644
index 0000000..35d15a6
--- /dev/null
+++ b/app/components/app-layout.tsx
@@ -0,0 +1,122 @@
+import { Headphones, Mic, Settings } from "lucide-react";
+import React from "react";
+import { NavLink } from "react-router";
+import { useShallow } from 'zustand/react/shallow';
+import { cn, getFirstLetters } from "~/lib/utils";
+import { useServerListStore } from "~/store/server-list";
+import { useUserStore } from "~/store/user";
+import { CreateServerButton } from "./create-server";
+import Discord from "./icons/Discord";
+import { OnlineStatus } from "./online-status";
+import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";
+import { Button } from "./ui/button";
+import { ScrollArea } from "./ui/scroll-area";
+import { Separator } from "./ui/separator";
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip";
+
+interface AppLayoutProps {
+ list: React.ReactNode;
+ children: React.ReactNode;
+}
+
+export default function AppLayout({ list, children }: AppLayoutProps) {
+ let user = useUserStore(state => state.user!)
+ let servers = useServerListStore(useShallow((state) => Array.from(state.servers.values())))
+
+ return (
+
+
+
+
+
+
+
+
+ {list}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {user.displayName || user.username}
+
+
@{user.username}
+
+
+
+
+
+
+
+
+
+ {children}
+
+
+ )
+}
\ No newline at end of file
diff --git a/app/components/channel-list-item.tsx b/app/components/channel-list-item.tsx
new file mode 100644
index 0000000..a2f5f58
--- /dev/null
+++ b/app/components/channel-list-item.tsx
@@ -0,0 +1,45 @@
+import { ChevronDown, Hash, Volume2 } from "lucide-react"
+import { Button } from "./ui/button"
+
+interface Channel {
+ id: string
+ name: string
+ type: "text" | "voice" | "category"
+}
+
+interface ChannelListItemProps {
+ channel: Channel
+}
+
+export default function ChannelListItem({ channel }: ChannelListItemProps) {
+ if (channel.type === "category") {
+ return (
+
+ )
+ }
+
+ return (
+ <>
+
+ >
+ )
+}
diff --git a/app/components/create-server.tsx b/app/components/create-server.tsx
new file mode 100644
index 0000000..fdd5375
--- /dev/null
+++ b/app/components/create-server.tsx
@@ -0,0 +1,113 @@
+import { CirclePlus } from "lucide-react";
+import React from "react";
+
+
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm } from "react-hook-form";
+import { z } from "zod";
+import { Button } from "~/components/ui/button";
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "~/components/ui/dialog";
+import server from "~/lib/api/client/server";
+import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "./ui/form";
+import { IconUploadField } from "./ui/icon-upload-field";
+import { Input } from "./ui/input";
+
+const schema = z.object({
+ name: z.string().min(1).max(32),
+ icon: z.instanceof(File).optional(),
+});
+
+export function CreateServerButton() {
+ const [open, setOpen] = React.useState(false);
+
+ let form = useForm>({
+ resolver: zodResolver(schema),
+ })
+
+ function onOpenChange(openState: boolean) {
+ setOpen(openState)
+
+ if (!openState) {
+ form.reset()
+ }
+ }
+
+ async function onSubmit(values: z.infer) {
+ const response = await server.create(values)
+
+ onOpenChange(false)
+ }
+
+ return (
+
+ )
+}
\ No newline at end of file
diff --git a/app/components/global-webrtc-audio-player.tsx b/app/components/global-webrtc-audio-player.tsx
new file mode 100644
index 0000000..73ea373
--- /dev/null
+++ b/app/components/global-webrtc-audio-player.tsx
@@ -0,0 +1,51 @@
+// ~/components/global-audio-player.tsx
+import { useEffect, useRef } from 'react';
+import { useWebRTCStore } from '~/store/webrtc';
+
+export function GlobalWebRTCAudioPlayer() {
+ const audioRef = useRef(null);
+ const remoteStream = useWebRTCStore(state => state.remoteStream);
+ const webRTCStatus = useWebRTCStore(state => state.status);
+
+ useEffect(() => {
+ const audioElement = audioRef.current;
+ if (audioElement) {
+ if (remoteStream && webRTCStatus === 'CONNECTED') {
+ console.log('GlobalAudioPlayer: Setting remote stream to audio element.');
+ if (audioElement.srcObject !== remoteStream) { // Avoid unnecessary re-assignments
+ audioElement.srcObject = remoteStream;
+ audioElement.play().catch(error => {
+ // Autoplay policy might prevent play without user interaction.
+ // You might need a UI element for the user to click to start playback.
+ console.warn('GlobalAudioPlayer: Error trying to play audio automatically:', error);
+ // A common pattern is to mute the element initially and then allow unmuting by user action
+ // audioElement.muted = true; // Then provide an unmute button
+ });
+ }
+ } else {
+ // If stream is null or not connected, clear the srcObject
+ // This also handles the case when leaving a channel.
+ if (audioElement.srcObject) {
+ console.log('GlobalAudioPlayer: Clearing remote stream from audio element.');
+ audioElement.srcObject = null;
+ }
+ }
+ }
+ }, [remoteStream, webRTCStatus]); // Re-run when remoteStream or connection status changes
+
+ // This component renders the audio element.
+ // It's generally good practice for