.
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -4,3 +4,4 @@
|
|||||||
# React Router
|
# React Router
|
||||||
/.react-router/
|
/.react-router/
|
||||||
/build/
|
/build/
|
||||||
|
/.idea/
|
||||||
193
app/app.css
193
app/app.css
@@ -8,11 +8,101 @@
|
|||||||
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--background: oklch(1.00 0 0);
|
||||||
|
--foreground: oklch(0.32 0 0);
|
||||||
|
--card: oklch(1.00 0 0);
|
||||||
|
--card-foreground: oklch(0.32 0 0);
|
||||||
|
--popover: oklch(1.00 0 0);
|
||||||
|
--popover-foreground: oklch(0.32 0 0);
|
||||||
|
--primary: oklch(0.62 0.19 259.81);
|
||||||
|
--primary-foreground: oklch(1.00 0 0);
|
||||||
|
--secondary: oklch(0.97 0.00 264.54);
|
||||||
|
--secondary-foreground: oklch(0.45 0.03 256.80);
|
||||||
|
--muted: oklch(0.98 0.00 247.84);
|
||||||
|
--muted-foreground: oklch(0.55 0.02 264.36);
|
||||||
|
--accent: oklch(0.95 0.03 236.82);
|
||||||
|
--accent-foreground: oklch(0.38 0.14 265.52);
|
||||||
|
--destructive: oklch(0.64 0.21 25.33);
|
||||||
|
--destructive-foreground: oklch(1.00 0 0);
|
||||||
|
--border: oklch(0.93 0.01 264.53);
|
||||||
|
--input: oklch(0.93 0.01 264.53);
|
||||||
|
--ring: oklch(0.62 0.19 259.81);
|
||||||
|
--chart-1: oklch(0.62 0.19 259.81);
|
||||||
|
--chart-2: oklch(0.55 0.22 262.88);
|
||||||
|
--chart-3: oklch(0.49 0.22 264.38);
|
||||||
|
--chart-4: oklch(0.42 0.18 265.64);
|
||||||
|
--chart-5: oklch(0.38 0.14 265.52);
|
||||||
|
--sidebar: oklch(0.98 0.00 247.84);
|
||||||
|
--sidebar-foreground: oklch(0.32 0 0);
|
||||||
|
--sidebar-primary: oklch(0.62 0.19 259.81);
|
||||||
|
--sidebar-primary-foreground: oklch(1.00 0 0);
|
||||||
|
--sidebar-accent: oklch(0.95 0.03 236.82);
|
||||||
|
--sidebar-accent-foreground: oklch(0.38 0.14 265.52);
|
||||||
|
--sidebar-border: oklch(0.93 0.01 264.53);
|
||||||
|
--sidebar-ring: oklch(0.62 0.19 259.81);
|
||||||
|
--font-sans: Inter, sans-serif;
|
||||||
|
--font-serif: Source Serif 4, serif;
|
||||||
|
--font-mono: JetBrains Mono, monospace;
|
||||||
|
--radius: 0.375rem;
|
||||||
|
--shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
|
||||||
|
--shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
|
||||||
|
--shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
|
||||||
|
--shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
|
||||||
|
--shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 2px 4px -1px hsl(0 0% 0% / 0.10);
|
||||||
|
--shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 4px 6px -1px hsl(0 0% 0% / 0.10);
|
||||||
|
--shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 8px 10px -1px hsl(0 0% 0% / 0.10);
|
||||||
|
--shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: oklch(0.20 0 0);
|
||||||
|
--foreground: oklch(0.92 0 0);
|
||||||
|
--card: oklch(0.27 0 0);
|
||||||
|
--card-foreground: oklch(0.92 0 0);
|
||||||
|
--popover: oklch(0.27 0 0);
|
||||||
|
--popover-foreground: oklch(0.92 0 0);
|
||||||
|
--primary: oklch(0.62 0.19 259.81);
|
||||||
|
--primary-foreground: oklch(1.00 0 0);
|
||||||
|
--secondary: oklch(0.27 0 0);
|
||||||
|
--secondary-foreground: oklch(0.92 0 0);
|
||||||
|
--muted: oklch(0.27 0 0);
|
||||||
|
--muted-foreground: oklch(0.72 0 0);
|
||||||
|
--accent: oklch(0.38 0.14 265.52);
|
||||||
|
--accent-foreground: oklch(0.88 0.06 254.13);
|
||||||
|
--destructive: oklch(0.64 0.21 25.33);
|
||||||
|
--destructive-foreground: oklch(1.00 0 0);
|
||||||
|
--border: oklch(0.37 0 0);
|
||||||
|
--input: oklch(0.37 0 0);
|
||||||
|
--ring: oklch(0.62 0.19 259.81);
|
||||||
|
--chart-1: oklch(0.71 0.14 254.62);
|
||||||
|
--chart-2: oklch(0.62 0.19 259.81);
|
||||||
|
--chart-3: oklch(0.55 0.22 262.88);
|
||||||
|
--chart-4: oklch(0.49 0.22 264.38);
|
||||||
|
--chart-5: oklch(0.42 0.18 265.64);
|
||||||
|
--sidebar: oklch(0.20 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.92 0 0);
|
||||||
|
--sidebar-primary: oklch(0.62 0.19 259.81);
|
||||||
|
--sidebar-primary-foreground: oklch(1.00 0 0);
|
||||||
|
--sidebar-accent: oklch(0.38 0.14 265.52);
|
||||||
|
--sidebar-accent-foreground: oklch(0.88 0.06 254.13);
|
||||||
|
--sidebar-border: oklch(0.37 0 0);
|
||||||
|
--sidebar-ring: oklch(0.62 0.19 259.81);
|
||||||
|
--font-sans: Inter, sans-serif;
|
||||||
|
--font-serif: Source Serif 4, serif;
|
||||||
|
--font-mono: JetBrains Mono, monospace;
|
||||||
|
--radius: 0.375rem;
|
||||||
|
--shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
|
||||||
|
--shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
|
||||||
|
--shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
|
||||||
|
--shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
|
||||||
|
--shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 2px 4px -1px hsl(0 0% 0% / 0.10);
|
||||||
|
--shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 4px 6px -1px hsl(0 0% 0% / 0.10);
|
||||||
|
--shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 8px 10px -1px hsl(0 0% 0% / 0.10);
|
||||||
|
--shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
@theme inline {
|
@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-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
--color-card: var(--card);
|
--color-card: var(--card);
|
||||||
@@ -28,6 +118,7 @@
|
|||||||
--color-accent: var(--accent);
|
--color-accent: var(--accent);
|
||||||
--color-accent-foreground: var(--accent-foreground);
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
--color-destructive: var(--destructive);
|
--color-destructive: var(--destructive);
|
||||||
|
--color-destructive-foreground: var(--destructive-foreground);
|
||||||
--color-border: var(--border);
|
--color-border: var(--border);
|
||||||
--color-input: var(--input);
|
--color-input: var(--input);
|
||||||
--color-ring: var(--ring);
|
--color-ring: var(--ring);
|
||||||
@@ -44,78 +135,37 @@
|
|||||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
--color-sidebar-border: var(--sidebar-border);
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
--color-sidebar-ring: var(--sidebar-ring);
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
}
|
|
||||||
|
|
||||||
:root {
|
--font-sans: var(--font-sans);
|
||||||
--radius: 0.625rem;
|
--font-mono: var(--font-mono);
|
||||||
--background: oklch(1 0 0);
|
--font-serif: var(--font-serif);
|
||||||
--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 {
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
--background: oklch(0.145 0 0);
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
--foreground: oklch(0.985 0 0);
|
--radius-lg: var(--radius);
|
||||||
--card: oklch(0.205 0 0);
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
--card-foreground: oklch(0.985 0 0);
|
|
||||||
--popover: oklch(0.205 0 0);
|
--shadow-2xs: var(--shadow-2xs);
|
||||||
--popover-foreground: oklch(0.985 0 0);
|
--shadow-xs: var(--shadow-xs);
|
||||||
--primary: oklch(0.922 0 0);
|
--shadow-sm: var(--shadow-sm);
|
||||||
--primary-foreground: oklch(0.205 0 0);
|
--shadow: var(--shadow);
|
||||||
--secondary: oklch(0.269 0 0);
|
--shadow-md: var(--shadow-md);
|
||||||
--secondary-foreground: oklch(0.985 0 0);
|
--shadow-lg: var(--shadow-lg);
|
||||||
--muted: oklch(0.269 0 0);
|
--shadow-xl: var(--shadow-xl);
|
||||||
--muted-foreground: oklch(0.708 0 0);
|
--shadow-2xl: var(--shadow-2xl);
|
||||||
--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 {
|
@layer base {
|
||||||
|
/* width */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
@apply w-1
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Handle */
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
@apply bg-border rounded-full mr-0.5
|
||||||
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
}
|
}
|
||||||
@@ -131,6 +181,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
|
|
||||||
/* Hide scrollbar for Chrome, Safari and Opera */
|
/* Hide scrollbar for Chrome, Safari and Opera */
|
||||||
.no-scrollbar::-webkit-scrollbar {
|
.no-scrollbar::-webkit-scrollbar {
|
||||||
display: none;
|
display: none;
|
||||||
|
|||||||
@@ -1,72 +1,41 @@
|
|||||||
import { Headphones, Mic, Settings } from "lucide-react";
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { NavLink } from "react-router";
|
import { useMatches } from "react-router";
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import { cn, getFirstLetters } from "~/lib/utils";
|
import { useServerListStore } from "~/stores/server-list-store";
|
||||||
import { useServerListStore } from "~/store/server-list";
|
import { CreateServerButton } from "./custom-ui/create-server-button";
|
||||||
import { useUserStore } from "~/store/user";
|
import { HomeButton } from "./custom-ui/home-button";
|
||||||
import { CreateServerButton } from "./create-server";
|
import { ServerButton } from "./custom-ui/server-button";
|
||||||
import Discord from "./icons/Discord";
|
import UserStatus from "./custom-ui/user-status";
|
||||||
import { OnlineStatus } from "./online-status";
|
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";
|
|
||||||
import { Button } from "./ui/button";
|
|
||||||
import { ScrollArea } from "./ui/scroll-area";
|
import { ScrollArea } from "./ui/scroll-area";
|
||||||
import { Separator } from "./ui/separator";
|
import { Separator } from "./ui/separator";
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip";
|
|
||||||
|
|
||||||
interface AppLayoutProps {
|
interface AppLayoutProps {
|
||||||
list: React.ReactNode;
|
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AppLayout({ list, children }: AppLayoutProps) {
|
export default function AppLayout({ children }: AppLayoutProps) {
|
||||||
let user = useUserStore(state => state.user!)
|
let servers = useServerListStore(useShallow((state) => Object.values(state.servers)))
|
||||||
let servers = useServerListStore(useShallow((state) => Array.from(state.servers.values())))
|
|
||||||
|
const matches = useMatches();
|
||||||
|
|
||||||
|
let list = React.useMemo(() => {
|
||||||
|
return matches.map(match => (match.handle as {
|
||||||
|
listComponent?: React.ReactNode
|
||||||
|
})?.listComponent).reverse().find(component => !!component)
|
||||||
|
}, [matches])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen w-screen overflow-hidden">
|
<div className="flex h-screen w-screen overflow-hidden">
|
||||||
<div className="grid h-full max-h-screen bg-accent/60 min-w-80" style={{ gridTemplateColumns: "auto 1fr", gridTemplateRows: "1fr auto" }}>
|
<div className="grid h-full max-h-screen bg-background min-w-80 border-r-2" style={{ gridTemplateColumns: "auto 1fr", gridTemplateRows: "1fr auto" }}>
|
||||||
<div className="col-start-1 row-start-1 col-span-1 row-span-1 overflow-hidden border-r-2 min-w-fit">
|
<div className="col-start-1 row-start-1 col-span-1 row-span-1 overflow-hidden border-r-2 min-w-fit">
|
||||||
<ScrollArea className="h-full px-1" scrollbarSize="none">
|
<ScrollArea className="h-full px-1" scrollbarSize="none">
|
||||||
<aside className="flex flex-col gap-4 p-2 h-full">
|
<aside className="flex flex-col gap-4 p-2 h-full">
|
||||||
<NavLink to={`/app/@me`} className={({ isActive }) =>
|
<HomeButton />
|
||||||
cn(
|
|
||||||
"rounded-xl",
|
|
||||||
isActive ? "bg-primary" : "bg-accent",
|
|
||||||
)
|
|
||||||
}>
|
|
||||||
<Discord className="size-8 m-2" />
|
|
||||||
</NavLink>
|
|
||||||
<Separator />
|
<Separator />
|
||||||
{
|
{
|
||||||
servers.map((server, _) =>
|
servers.map((server, _) =>
|
||||||
<React.Fragment key={server.id}>
|
<React.Fragment key={server.id}>
|
||||||
<TooltipProvider>
|
<ServerButton server={server} />
|
||||||
<Tooltip>
|
|
||||||
<NavLink to={`/app/server/${server.id}`} className={({ isActive }) =>
|
|
||||||
cn(
|
|
||||||
"rounded-xl overflow-hidden",
|
|
||||||
isActive ? "bg-primary" : "bg-accent",
|
|
||||||
)
|
|
||||||
}>
|
|
||||||
{({ isActive }) => (
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Avatar className="size-12 rounded-none">
|
|
||||||
<AvatarImage src={server.icon_url} className="rounded-none" />
|
|
||||||
<AvatarFallback className={cn(isActive ? "bg-primary text-accent" : "", "rounded-none")}>
|
|
||||||
<div>
|
|
||||||
{getFirstLetters(server.name, 4)}
|
|
||||||
</div>
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
</TooltipTrigger>
|
|
||||||
)}
|
|
||||||
</NavLink>
|
|
||||||
<TooltipContent>
|
|
||||||
{server.name}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -80,37 +49,7 @@ export default function AppLayout({ list, children }: AppLayoutProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="col-start-1 row-start-2 col-span-2 row-span-1 mb-2 mx-2 min-w-fit z-1">
|
<div className="col-start-1 row-start-2 col-span-2 row-span-1 mb-2 mx-2 min-w-fit z-1">
|
||||||
<div className="outline-1 p-2 h-full rounded-xl">
|
<UserStatus />
|
||||||
<div className="flex justify-between items-center gap-2">
|
|
||||||
<div className="grow flex flex-row gap-2">
|
|
||||||
<OnlineStatus status="online">
|
|
||||||
<Avatar className="size-10">
|
|
||||||
<AvatarImage src="https://api.dicebear.com/9.x/bottts/jpg?seed=lionarius" />
|
|
||||||
</Avatar>
|
|
||||||
</OnlineStatus>
|
|
||||||
|
|
||||||
<div className="flex flex-col text-sm justify-center">
|
|
||||||
<div className="truncate max-w-30">
|
|
||||||
{user.displayName || user.username}
|
|
||||||
</div>
|
|
||||||
<span className="text-muted-foreground text-xs">@{user.username}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="flex flex-row-reverse gap-2 items-center">
|
|
||||||
<Button variant="secondary" size="none">
|
|
||||||
<Settings className="size-5 m-1.5" />
|
|
||||||
</Button>
|
|
||||||
<Button variant="secondary" size="none">
|
|
||||||
<Headphones className="size-5 m-1.5" />
|
|
||||||
</Button>
|
|
||||||
<Button variant="secondary" size="none">
|
|
||||||
<Mic className="size-5 m-1.5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
84
app/components/channel-area.tsx
Normal file
84
app/components/channel-area.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { useInfiniteQuery, type QueryFunctionContext } from "@tanstack/react-query"
|
||||||
|
import type { Channel, MessageId } from "~/lib/api/types"
|
||||||
|
import ChatMessage from "./chat-message"
|
||||||
|
import MessageBox from "./message-box"
|
||||||
|
import VisibleTrigger from "./visible-trigger"
|
||||||
|
|
||||||
|
interface ChannelAreaProps {
|
||||||
|
channel: Channel
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ChannelArea(
|
||||||
|
{ channel }: ChannelAreaProps
|
||||||
|
) {
|
||||||
|
const channelId = channel.id
|
||||||
|
|
||||||
|
const fetchMessages = async ({ pageParam }: QueryFunctionContext) => {
|
||||||
|
return await import("~/lib/api/client/channel").then(m => m.default.paginatedMessages(channelId, 50, pageParam as MessageId | undefined))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
error,
|
||||||
|
fetchNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
isFetching,
|
||||||
|
isFetchingNextPage,
|
||||||
|
isPending,
|
||||||
|
status,
|
||||||
|
} = useInfiniteQuery({
|
||||||
|
queryKey: ["messages", channelId],
|
||||||
|
initialPageParam: undefined,
|
||||||
|
queryFn: fetchMessages,
|
||||||
|
getNextPageParam: (lastPage) => lastPage.length < 50 ? undefined : lastPage[lastPage.length - 1]?.id,
|
||||||
|
staleTime: Infinity,
|
||||||
|
})
|
||||||
|
|
||||||
|
const fetchNextPageVisible = () => {
|
||||||
|
if (!isFetchingNextPage && hasNextPage)
|
||||||
|
fetchNextPage()
|
||||||
|
}
|
||||||
|
|
||||||
|
let messageArea = null
|
||||||
|
|
||||||
|
if (isPending) {
|
||||||
|
messageArea = <div className="flex items-center justify-center size-full">
|
||||||
|
<span>Loading...</span>
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
messageArea = <>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<div className="flex flex-col-reverse overflow-auto gap-2">
|
||||||
|
{
|
||||||
|
status === "success" && data.pages.map((page, i) => (
|
||||||
|
page.map((message) => (
|
||||||
|
<div key={message.id} className="w-full">
|
||||||
|
<ChatMessage message={message} />
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
<VisibleTrigger triggerOnce={false} onVisible={fetchNextPageVisible} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col size-full">
|
||||||
|
<div className="w-full min-h-12 border-b-2 flex items-center justify-center">
|
||||||
|
{channel?.name}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto flex flex-col pl-2 pr-0.5">
|
||||||
|
{messageArea}
|
||||||
|
</div>
|
||||||
|
<div className="w-full max-w-full max-h-1/2">
|
||||||
|
<MessageBox channelId={channelId} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
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 (
|
|
||||||
<div className="text-xs flex flex-row justify-between mt-4">
|
|
||||||
<div className="grow">
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
{channel.name}
|
|
||||||
<ChevronDown className="size-4" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Button variant="secondary" size="sm" className="justify-start">
|
|
||||||
<div className="flex items-center gap-2 max-w-72">
|
|
||||||
<div>
|
|
||||||
{channel.type === "text" && <Hash />}
|
|
||||||
{channel.type === "voice" && <Volume2 />}
|
|
||||||
</div>
|
|
||||||
<div className="truncate">
|
|
||||||
{channel.name}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
136
app/components/chat-message-attachment.tsx
Normal file
136
app/components/chat-message-attachment.tsx
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import {
|
||||||
|
Download,
|
||||||
|
ExternalLink,
|
||||||
|
Maximize
|
||||||
|
} from "lucide-react";
|
||||||
|
import { AspectRatio } from "~/components/ui/aspect-ratio"; // Shadcn UI AspectRatio
|
||||||
|
import { Button } from "~/components/ui/button"; // Shadcn UI Button
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "~/components/ui/dialog"; // Shadcn UI Dialog
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "~/components/ui/tooltip"; // Shadcn UI Tooltip
|
||||||
|
import type { UploadedFile } from "~/lib/api/types"; // Adjust path as needed
|
||||||
|
import { formatFileSize } from "~/lib/utils"; // Adjust path
|
||||||
|
import { FileIcon } from "./file-icon";
|
||||||
|
|
||||||
|
interface ChatMessageAttachmentProps {
|
||||||
|
file: UploadedFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ChatMessageAttachment({ file }: ChatMessageAttachmentProps) {
|
||||||
|
if (file.contentType.startsWith("image/")) {
|
||||||
|
return <ImageAttachment file={file} />;
|
||||||
|
}
|
||||||
|
return <GenericFileAttachment file={file} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function GenericFileAttachment({ file }: ChatMessageAttachmentProps) {
|
||||||
|
return (
|
||||||
|
<TooltipProvider delayDuration={100}>
|
||||||
|
<div className="flex items-center gap-3 rounded-lg border bg-card p-3 shadow-sm w-full max-w-xs sm:max-w-sm">
|
||||||
|
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-md bg-muted">
|
||||||
|
<FileIcon className="h-8 w-8 text-muted-foreground" contentType={file.contentType} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
<p className="truncate text-sm font-medium text-card-foreground">
|
||||||
|
{file.filename}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{formatFileSize(file.size)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" asChild>
|
||||||
|
<a href={file.url} target="_blank" rel="noreferrer" download={file.filename}>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Download</span>
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Download</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" asChild>
|
||||||
|
<a href={file.url} target="_blank" rel="noreferrer">
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Open in new tab</span>
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Open in new tab</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ImageAttachment({ file }: ChatMessageAttachmentProps) {
|
||||||
|
return (
|
||||||
|
<Dialog>
|
||||||
|
<TooltipProvider delayDuration={100}>
|
||||||
|
<div className="group relative w-48 cursor-pointer sm:w-64">
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<AspectRatio ratio={16 / 9} className="overflow-hidden rounded-lg border bg-muted">
|
||||||
|
<img
|
||||||
|
src={file.url}
|
||||||
|
alt={file.filename}
|
||||||
|
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-black/30 opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
|
<Maximize className="h-8 w-8 text-white" />
|
||||||
|
</div>
|
||||||
|
</AspectRatio>
|
||||||
|
</DialogTrigger>
|
||||||
|
<div className="mt-1">
|
||||||
|
<p className="truncate text-xs text-muted-foreground">
|
||||||
|
{file.filename} ({formatFileSize(file.size)})
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
|
||||||
|
<DialogContent className="max-w-3xl p-0">
|
||||||
|
<DialogHeader className="p-4 pb-0">
|
||||||
|
<DialogTitle className="truncate">{file.filename}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="p-4 pt-0 max-h-[70vh] overflow-y-auto">
|
||||||
|
<img
|
||||||
|
src={file.url}
|
||||||
|
alt={file.filename}
|
||||||
|
className="mx-auto max-h-full w-auto rounded-md object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DialogFooter className="flex-col items-stretch gap-2 border-t p-4 sm:flex-row sm:justify-end sm:space-x-2">
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<a href={file.url} target="_blank" rel="noreferrer">
|
||||||
|
<ExternalLink className="mr-2 h-4 w-4" /> Open original
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
<Button asChild>
|
||||||
|
<a href={file.url} download={file.filename}>
|
||||||
|
<Download className="mr-2 h-4 w-4" /> Download
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button variant="ghost">Close</Button>
|
||||||
|
</DialogClose>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
86
app/components/chat-message.tsx
Normal file
86
app/components/chat-message.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { Clock } from "lucide-react"
|
||||||
|
import React from "react"
|
||||||
|
import { useShallow } from "zustand/react/shallow"
|
||||||
|
import type { Message } from "~/lib/api/types"
|
||||||
|
import { useUsersStore } from "~/stores/users-store"
|
||||||
|
import ChatMessageAttachment from "./chat-message-attachment"
|
||||||
|
import UserAvatar from "./user-avatar"
|
||||||
|
|
||||||
|
interface ChatMessageProps {
|
||||||
|
message: Message
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ChatMessage(
|
||||||
|
{ message }: ChatMessageProps
|
||||||
|
) {
|
||||||
|
const { user, fetchUsersIfNotPresent } = useUsersStore(useShallow(state => ({
|
||||||
|
user: state.users[message.authorId],
|
||||||
|
fetchUsersIfNotPresent: state.fetchUsersIfNotPresent
|
||||||
|
})))
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
fetchUsersIfNotPresent([message.authorId])
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const formatMessageDate = (date: Date) => {
|
||||||
|
const now = new Date()
|
||||||
|
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||||
|
const yesterday = new Date(today)
|
||||||
|
yesterday.setDate(yesterday.getDate() - 1)
|
||||||
|
|
||||||
|
const messageDate = new Date(date.getFullYear(), date.getMonth(), date.getDate())
|
||||||
|
|
||||||
|
// Get localized time string
|
||||||
|
const timeString = date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false })
|
||||||
|
const capitalize = (str: string) => str.charAt(0).toUpperCase() + str.slice(1)
|
||||||
|
|
||||||
|
if (messageDate.getTime() === today.getTime()) {
|
||||||
|
// Use Intl.RelativeTimeFormat for localized "Today"
|
||||||
|
const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
|
||||||
|
return `${(capitalize(rtf.format(0, 'day')))}, ${timeString}`
|
||||||
|
} else if (messageDate.getTime() === yesterday.getTime()) {
|
||||||
|
// Use Intl.RelativeTimeFormat for localized "Yesterday"
|
||||||
|
const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
|
||||||
|
return `${capitalize(rtf.format(-1, 'day'))}, ${timeString}`
|
||||||
|
} else {
|
||||||
|
return date.toLocaleDateString(undefined, {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-x-2" style={{ gridTemplateColumns: "auto 1fr", gridTemplateRows: "auto 1fr" }}>
|
||||||
|
<div className="row-start-1 col-start-1 row-span-2 col-span-1">
|
||||||
|
<UserAvatar user={user} />
|
||||||
|
</div>
|
||||||
|
<div className="row-start-1 col-start-2 row-span-1 col-span-1 flex items-center gap-2">
|
||||||
|
<span className="font-medium text-sm">
|
||||||
|
{user?.displayName || user?.username}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-0.5 text-xs text-muted-foreground whitespace-nowrap">
|
||||||
|
<Clock className="size-3" />
|
||||||
|
<span>
|
||||||
|
{formatMessageDate(message.createdAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="row-start-2 col-start-2 row-span-1 col-span-1">
|
||||||
|
<div className="wrap-break-word contain-inline-size">
|
||||||
|
{message.content}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{
|
||||||
|
message.attachments.map((file, i) => (<div key={file.id}>
|
||||||
|
<ChatMessageAttachment file={file} />
|
||||||
|
</div>))
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
114
app/components/custom-ui/channel-list-item.tsx
Normal file
114
app/components/custom-ui/channel-list-item.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { ChevronDown, Hash, Volume2 } from "lucide-react"
|
||||||
|
import React from "react"
|
||||||
|
import { NavLink } from "react-router"
|
||||||
|
import { useShallow } from "zustand/react/shallow"
|
||||||
|
import { ChannelType, type ServerChannel } from "~/lib/api/types"
|
||||||
|
import { cn } from "~/lib/utils"
|
||||||
|
import { useChannelsVoiceStateStore } from "~/stores/channels-voice-state"
|
||||||
|
import { useGatewayStore } from "~/stores/gateway-store"
|
||||||
|
import { useUsersStore } from "~/stores/users-store"
|
||||||
|
import { Button } from "../ui/button"
|
||||||
|
import UserAvatar from "../user-avatar"
|
||||||
|
|
||||||
|
interface ChannelListItemProps {
|
||||||
|
channel: ServerChannel
|
||||||
|
}
|
||||||
|
|
||||||
|
function ServerCategory({ channel }: ChannelListItemProps) {
|
||||||
|
return (
|
||||||
|
<div className="text-xs flex flex-row justify-between mt-4">
|
||||||
|
<div className="grow">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{channel.name}
|
||||||
|
<ChevronDown className="size-4" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ServerVoice({ channel }: ChannelListItemProps) {
|
||||||
|
const updateVoiceState = useGatewayStore(state => state.updateVoiceState)
|
||||||
|
const channelVoiceState = useChannelsVoiceStateStore(state => state.channels[channel.id]) || {}
|
||||||
|
const userIds = Object.keys(channelVoiceState.users ?? {})
|
||||||
|
|
||||||
|
const { users, fetchUsersIfNotPresent } = useUsersStore(useShallow(state => ({
|
||||||
|
users: state.users,
|
||||||
|
fetchUsersIfNotPresent: state.fetchUsersIfNotPresent
|
||||||
|
})))
|
||||||
|
|
||||||
|
const channelUsers = React.useMemo(() => userIds.map(userId => users[userId]).filter(Boolean), [userIds, users])
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
fetchUsersIfNotPresent(userIds)
|
||||||
|
}, [userIds])
|
||||||
|
|
||||||
|
const onClick = () => {
|
||||||
|
updateVoiceState(channel.serverId, channel.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button variant="secondary" size="sm" className="justify-start" onClick={onClick}>
|
||||||
|
<div className="flex items-center gap-2 max-w-72">
|
||||||
|
<div>
|
||||||
|
<Volume2 />
|
||||||
|
</div>
|
||||||
|
<div className="truncate">
|
||||||
|
{channel.name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
{channelUsers.length > 0 &&
|
||||||
|
<div className="ml-2 border-l-2 flex flex-col gap-1">
|
||||||
|
{
|
||||||
|
channelUsers
|
||||||
|
.map(user => (
|
||||||
|
<div key={user.id} className="flex items-center gap-2 max-w-72 pl-4">
|
||||||
|
<UserAvatar user={user} className="size-6" />
|
||||||
|
{user.displayName || user.username}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</div>}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ServerText({ channel }: ChannelListItemProps) {
|
||||||
|
return (
|
||||||
|
<NavLink to={`/app/server/${channel.serverId}/${channel.id}`} discover="none" >
|
||||||
|
{({ isActive }) => (
|
||||||
|
<Button variant="secondary" size="sm" className={
|
||||||
|
cn(
|
||||||
|
"justify-start w-full",
|
||||||
|
isActive ? "bg-accent hover:bg-accent" : "bg-secondary"
|
||||||
|
)
|
||||||
|
}>
|
||||||
|
<div className="flex items-center gap-2 max-w-72">
|
||||||
|
<div>
|
||||||
|
<Hash />
|
||||||
|
</div>
|
||||||
|
<div className="truncate">
|
||||||
|
{channel.name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</NavLink>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ServerChannelListItem({ channel }: ChannelListItemProps) {
|
||||||
|
switch (channel.type) {
|
||||||
|
case ChannelType.SERVER_CATEGORY:
|
||||||
|
return <ServerCategory channel={channel} />
|
||||||
|
case ChannelType.SERVER_VOICE:
|
||||||
|
return <ServerVoice channel={channel} />
|
||||||
|
case ChannelType.SERVER_TEXT:
|
||||||
|
return <ServerText channel={channel} />
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
15
app/components/custom-ui/create-server-button.tsx
Normal file
15
app/components/custom-ui/create-server-button.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { CirclePlus } from "lucide-react";
|
||||||
|
|
||||||
|
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { ModalType, useModalStore } from "~/stores/modal-store";
|
||||||
|
|
||||||
|
export function CreateServerButton() {
|
||||||
|
const onOpen = useModalStore(state => state.onOpen)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button variant="outline" size="none" onClick={() => onOpen(ModalType.CREATE_SERVER)}>
|
||||||
|
<CirclePlus className="size-8 m-2" />
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
21
app/components/custom-ui/home-button.tsx
Normal file
21
app/components/custom-ui/home-button.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { NavLink } from "react-router";
|
||||||
|
import Discord from "../icons/Discord";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
|
||||||
|
export function HomeButton() {
|
||||||
|
return (
|
||||||
|
<NavLink to={`/app/@me`}>
|
||||||
|
{
|
||||||
|
({ isActive }) => (
|
||||||
|
<Button variant="outline" size="none" asChild className={
|
||||||
|
isActive ? "bg-accent size-12" : "size-12"
|
||||||
|
}>
|
||||||
|
<div>
|
||||||
|
<Discord className="size-full p-2" />
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</NavLink>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ export function OnlineStatus({
|
|||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div {...props}></div>
|
<div {...props}></div>
|
||||||
<div className="absolute bottom-0 right-0 bg-accent rounded-full p-0.5 size-1/2">
|
<div className="absolute bottom-0 right-0 bg-background rounded-full p-0.5 size-1/2">
|
||||||
{status === "online" && <Circle className="size-full stroke-emerald-400 fill-emerald-400" />}
|
{status === "online" && <Circle className="size-full stroke-emerald-400 fill-emerald-400" />}
|
||||||
{status === "dnd" && <CircleMinus className="size-full stroke-red-400 stroke-3" />}
|
{status === "dnd" && <CircleMinus className="size-full stroke-red-400 stroke-3" />}
|
||||||
{status === "idle" && <Moon className="size-full stroke-amber-400 fill-amber-400" />}
|
{status === "idle" && <Moon className="size-full stroke-amber-400 fill-amber-400" />}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { EyeIcon, EyeOffIcon } from "lucide-react"
|
import { EyeIcon, EyeOffIcon } from "lucide-react"
|
||||||
import React from "react"
|
import React from "react"
|
||||||
import { Button } from "./ui/button"
|
import { Button } from "../ui/button"
|
||||||
import { Input } from "./ui/input"
|
import { Input } from "../ui/input"
|
||||||
|
|
||||||
export function PasswordInput(props: React.ComponentProps<"input">) {
|
export function PasswordInput(props: React.ComponentProps<"input">) {
|
||||||
const [showPassword, setShowPassword] = React.useState(false)
|
const [showPassword, setShowPassword] = React.useState(false)
|
||||||
48
app/components/custom-ui/private-channel-list-item.tsx
Normal file
48
app/components/custom-ui/private-channel-list-item.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { Check } from "lucide-react"
|
||||||
|
import { NavLink } from "react-router"
|
||||||
|
import type { RecipientChannel } from "~/lib/api/types"
|
||||||
|
import { cn } from "~/lib/utils"
|
||||||
|
import { useUsersStore } from "~/stores/users-store"
|
||||||
|
import { Badge } from "../ui/badge"
|
||||||
|
import { Button } from "../ui/button"
|
||||||
|
import UserAvatar from "../user-avatar"
|
||||||
|
import { OnlineStatus } from "./online-status"
|
||||||
|
|
||||||
|
interface PrivateChannelListItemProps {
|
||||||
|
channel: RecipientChannel
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PrivateChannelListItem({ channel }: PrivateChannelListItemProps) {
|
||||||
|
const currentUserId = useUsersStore(state => state.currentUserId)
|
||||||
|
const recipients = channel.recipients.filter(recipient => recipient.id !== currentUserId);
|
||||||
|
const renderSystemBadge = recipients.some(recipient => recipient.system) && recipients.length === 1
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<NavLink to={`/app/@me/channels/${channel.id}`}>
|
||||||
|
{
|
||||||
|
({ isActive }) => (
|
||||||
|
<Button variant="secondary" size="none" asChild className={
|
||||||
|
cn(
|
||||||
|
isActive ? "bg-accent hover:bg-accent" : "",
|
||||||
|
"w-full flex flex-row justify-start"
|
||||||
|
)
|
||||||
|
}>
|
||||||
|
<div className="flex items-center gap-2 max-w-72 p-2">
|
||||||
|
<div>
|
||||||
|
<OnlineStatus status="online">
|
||||||
|
<UserAvatar user={channel.recipients.find(recipient => recipient.id !== currentUserId)} />
|
||||||
|
</OnlineStatus>
|
||||||
|
</div>
|
||||||
|
<div className="truncate">
|
||||||
|
{recipients.map(recipient => recipient.displayName || recipient.username).join(", ")}
|
||||||
|
</div>
|
||||||
|
{renderSystemBadge && <Badge variant="default"> <Check /> System</Badge>}
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</NavLink>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
36
app/components/custom-ui/server-button.tsx
Normal file
36
app/components/custom-ui/server-button.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "@radix-ui/react-avatar"
|
||||||
|
import { NavLink } from "react-router"
|
||||||
|
import type { Server } from "~/lib/api/types"
|
||||||
|
import { getFirstLetters } from "~/lib/utils"
|
||||||
|
import { Button } from "../ui/button"
|
||||||
|
|
||||||
|
export interface ServerButtonProps {
|
||||||
|
server: Server
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ServerButton(
|
||||||
|
{ server }: ServerButtonProps
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<NavLink to={`/app/server/${server.id}`}>
|
||||||
|
{
|
||||||
|
({ isActive }) => (
|
||||||
|
<Button variant="outline" size="none" asChild className={
|
||||||
|
isActive ? "bg-accent" : ""
|
||||||
|
}>
|
||||||
|
<div>
|
||||||
|
<Avatar className="size-12 rounded-none flex items-center justify-center">
|
||||||
|
<AvatarImage src={server.iconUrl} className="rounded-none" />
|
||||||
|
<AvatarFallback>
|
||||||
|
<div>
|
||||||
|
{getFirstLetters(server.name, 4)}
|
||||||
|
</div>
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</NavLink>
|
||||||
|
)
|
||||||
|
}
|
||||||
38
app/components/custom-ui/settings-button.tsx
Normal file
38
app/components/custom-ui/settings-button.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { Settings } from "lucide-react";
|
||||||
|
import { ModalType, useModalStore } from "~/stores/modal-store";
|
||||||
|
import { useTokenStore } from "~/stores/token-store";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "../ui/dropdown-menu";
|
||||||
|
|
||||||
|
export function SettingsButton() {
|
||||||
|
const setToken = useTokenStore(state => state.setToken)
|
||||||
|
const onOpen = useModalStore(state => state.onOpen)
|
||||||
|
|
||||||
|
const onUpdateProfile = () => {
|
||||||
|
onOpen(ModalType.UPDATE_PROFILE)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onLogout = () => {
|
||||||
|
setToken(undefined)
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<Settings className="size-5 m-1.5" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent>
|
||||||
|
<DropdownMenuItem onClick={onUpdateProfile}>
|
||||||
|
Update profile
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem variant="destructive" onClick={onLogout}>
|
||||||
|
Logout
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -86,8 +86,7 @@ export const TextBox = forwardRef<HTMLDivElement, TextBoxProps>(
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"max-h-96 w-full overflow-y-auto min-h-6 outline-none p-4 rounded-xl no-scrollbar bg-background border border-input",
|
"max-h-96 w-full overflow-y-auto min-h-6",
|
||||||
"focus-within:ring-2 focus-within:ring-ring focus-within:border-ring",
|
|
||||||
wrapperClassName
|
wrapperClassName
|
||||||
)}
|
)}
|
||||||
onClick={() => localRef.current?.focus()}
|
onClick={() => localRef.current?.focus()}
|
||||||
80
app/components/custom-ui/user-status.tsx
Normal file
80
app/components/custom-ui/user-status.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { PhoneMissed, Signal } from "lucide-react";
|
||||||
|
import { useServerChannelsStore } from "~/stores/server-channels-store";
|
||||||
|
import { useServerListStore } from "~/stores/server-list-store";
|
||||||
|
import { useUsersStore } from "~/stores/users-store";
|
||||||
|
import { useVoiceStateStore } from "~/stores/voice-state-store";
|
||||||
|
import { ThemeToggle } from "../theme/theme-toggle";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
import { Separator } from "../ui/separator";
|
||||||
|
import UserAvatar from "../user-avatar";
|
||||||
|
import { OnlineStatus } from "./online-status";
|
||||||
|
import { SettingsButton } from "./settings-button";
|
||||||
|
|
||||||
|
function VoiceStatus({
|
||||||
|
voiceState
|
||||||
|
}: { voiceState: { serverId: string; channelId: string } }) {
|
||||||
|
// const webrtcState = useWebRTCStore(state => state.status)
|
||||||
|
|
||||||
|
const leaveVoiceChannel = () => {
|
||||||
|
useVoiceStateStore.getState().leaveVoiceChannel()
|
||||||
|
}
|
||||||
|
|
||||||
|
const channel = useServerChannelsStore(state => state.channels[voiceState.serverId]?.[voiceState.channelId])
|
||||||
|
const server = useServerListStore(state => state.servers[voiceState.serverId])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="gap-1 flex justify-between items-center ">
|
||||||
|
<div className="flex items-center gap-2 text-green-500">
|
||||||
|
<Signal className="size-5" />
|
||||||
|
<div className="truncate max-w-60 text-sm">
|
||||||
|
{channel?.name || "Unknown channel"} / {server?.name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button variant="secondary" size="none" onClick={leaveVoiceChannel}>
|
||||||
|
<PhoneMissed className="size-5 m-1.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UserStatus() {
|
||||||
|
const user = useUsersStore(state => state.getCurrentUser()!)
|
||||||
|
const voiceState = useVoiceStateStore(state => state.activeChannel)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="outline-1 outline-none border border-input p-2 h-full rounded-xl flex flex-col gap-2">
|
||||||
|
{voiceState && <>
|
||||||
|
<VoiceStatus voiceState={voiceState} />
|
||||||
|
<Separator />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
<div className="flex justify-between items-center gap-2">
|
||||||
|
<div className="grow flex flex-row gap-2">
|
||||||
|
<OnlineStatus status="online">
|
||||||
|
<UserAvatar user={user} className="size-10" />
|
||||||
|
</OnlineStatus>
|
||||||
|
|
||||||
|
<div className="flex flex-col text-sm justify-center">
|
||||||
|
<div className="truncate max-w-30">
|
||||||
|
{user?.displayName || user?.username || "Unknown user"}
|
||||||
|
</div>
|
||||||
|
<span className="text-muted-foreground text-xs">@{user?.username}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-row-reverse gap-2 items-center">
|
||||||
|
<SettingsButton />
|
||||||
|
<ThemeToggle />
|
||||||
|
{/* <Button variant="secondary" size="none">
|
||||||
|
<Headphones className="size-5 m-1.5" />
|
||||||
|
</Button> */}
|
||||||
|
{/* <Button variant="secondary" size="none">
|
||||||
|
<Mic className="size-5 m-1.5" />
|
||||||
|
</Button> */}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
62
app/components/file-icon.tsx
Normal file
62
app/components/file-icon.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import {
|
||||||
|
FileArchive,
|
||||||
|
FileAudio,
|
||||||
|
FileQuestion,
|
||||||
|
FileText,
|
||||||
|
FileVideo,
|
||||||
|
ImageIcon,
|
||||||
|
type LucideProps,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
interface FileIconProps extends LucideProps {
|
||||||
|
contentType: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FileIcon({ contentType, className, ...props }: FileIconProps) {
|
||||||
|
const commonProps = { className: className ?? "h-5 w-5", ...props };
|
||||||
|
|
||||||
|
if (contentType.startsWith("image/")) {
|
||||||
|
return <ImageIcon {...commonProps} />;
|
||||||
|
}
|
||||||
|
if (contentType.startsWith("audio/")) {
|
||||||
|
return <FileAudio {...commonProps} />;
|
||||||
|
}
|
||||||
|
if (contentType.startsWith("video/")) {
|
||||||
|
return <FileVideo {...commonProps} />;
|
||||||
|
}
|
||||||
|
if (contentType === "application/pdf") {
|
||||||
|
return <FileText {...commonProps} />;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
contentType.startsWith("application/vnd.ms-excel") ||
|
||||||
|
contentType.startsWith("application/vnd.openxmlformats-officedocument.spreadsheetml")
|
||||||
|
) {
|
||||||
|
return <FileText {...commonProps} />; // Could use a specific Excel icon if available/desired
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
contentType.startsWith("application/msword") ||
|
||||||
|
contentType.startsWith("application/vnd.openxmlformats-officedocument.wordprocessingml")
|
||||||
|
) {
|
||||||
|
return <FileText {...commonProps} />;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
contentType.startsWith("application/vnd.ms-powerpoint") ||
|
||||||
|
contentType.startsWith("application/vnd.openxmlformats-officedocument.presentationml")
|
||||||
|
) {
|
||||||
|
return <FileText {...commonProps} />;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
contentType === "application/zip" ||
|
||||||
|
contentType === "application/x-rar-compressed" ||
|
||||||
|
contentType === "application/x-7z-compressed" ||
|
||||||
|
contentType === "application/gzip" ||
|
||||||
|
contentType === "application/x-tar"
|
||||||
|
) {
|
||||||
|
return <FileArchive {...commonProps} />;
|
||||||
|
}
|
||||||
|
if (contentType.startsWith("text/")) {
|
||||||
|
return <FileText {...commonProps} />;
|
||||||
|
}
|
||||||
|
return <FileQuestion {...commonProps} />; // Default for unknown types
|
||||||
|
}
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
// ~/components/global-audio-player.tsx
|
|
||||||
import { useEffect, useRef } from 'react';
|
|
||||||
import { useWebRTCStore } from '~/store/webrtc';
|
|
||||||
|
|
||||||
export function GlobalWebRTCAudioPlayer() {
|
|
||||||
const audioRef = useRef<HTMLAudioElement>(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 <audio> and <video> to have controls if user interaction is expected.
|
|
||||||
// For background audio, you might hide it, but be mindful of autoplay policies.
|
|
||||||
// `playsInline` is more relevant for video but doesn't hurt.
|
|
||||||
// `autoPlay` is desired but subject to browser restrictions.
|
|
||||||
return (
|
|
||||||
<audio
|
|
||||||
ref={audioRef}
|
|
||||||
autoPlay
|
|
||||||
playsInline
|
|
||||||
// controls // Optional: for debugging or if you want user to control volume/mute from here
|
|
||||||
// muted={true} // Start muted if autoplay is problematic, then provide an unmute button
|
|
||||||
style={{ display: 'none' }} // Hide it if it's just for background audio
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { SVGProps } from "react";
|
import type { SVGProps } from "react";
|
||||||
|
|
||||||
const Discord = (props: SVGProps<SVGSVGElement>) => <svg viewBox="0 0 256 199" width="1em" height="1em" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid" {...props}><path d="M216.856 16.597A208.502 208.502 0 0 0 164.042 0c-2.275 4.113-4.933 9.645-6.766 14.046-19.692-2.961-39.203-2.961-58.533 0-1.832-4.4-4.55-9.933-6.846-14.046a207.809 207.809 0 0 0-52.855 16.638C5.618 67.147-3.443 116.4 1.087 164.956c22.169 16.555 43.653 26.612 64.775 33.193A161.094 161.094 0 0 0 79.735 175.3a136.413 136.413 0 0 1-21.846-10.632 108.636 108.636 0 0 0 5.356-4.237c42.122 19.702 87.89 19.702 129.51 0a131.66 131.66 0 0 0 5.355 4.237 136.07 136.07 0 0 1-21.886 10.653c4.006 8.02 8.638 15.67 13.873 22.848 21.142-6.58 42.646-16.637 64.815-33.213 5.316-56.288-9.08-105.09-38.056-148.36ZM85.474 135.095c-12.645 0-23.015-11.805-23.015-26.18s10.149-26.2 23.015-26.2c12.867 0 23.236 11.804 23.015 26.2.02 14.375-10.148 26.18-23.015 26.18Zm85.051 0c-12.645 0-23.014-11.805-23.014-26.18s10.148-26.2 23.014-26.2c12.867 0 23.236 11.804 23.015 26.2 0 14.375-10.148 26.18-23.015 26.18Z" fill="#5865F2" /></svg>;
|
const Discord = (props: SVGProps<SVGSVGElement>) => <svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" preserveAspectRatio="xMidYMid" viewBox="0 0 256 226" {...props}><path fill="#1185FE" d="M55.491 15.172c29.35 22.035 60.917 66.712 72.509 90.686 11.592-23.974 43.159-68.651 72.509-90.686C221.686-.727 256-13.028 256 26.116c0 7.818-4.482 65.674-7.111 75.068-9.138 32.654-42.436 40.983-72.057 35.942 51.775 8.812 64.946 38 36.501 67.187-54.021 55.433-77.644-13.908-83.696-31.676-1.11-3.257-1.63-4.78-1.637-3.485-.008-1.296-.527.228-1.637 3.485-6.052 17.768-29.675 87.11-83.696 31.676-28.445-29.187-15.274-58.375 36.5-67.187-29.62 5.041-62.918-3.288-72.056-35.942C4.482 91.79 0 33.934 0 26.116 0-13.028 34.314-.727 55.491 15.172Z" /></svg>;
|
||||||
|
|
||||||
export default Discord;
|
export default Discord;
|
||||||
@@ -1,47 +1,42 @@
|
|||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useGatewayWebSocketStore } from '~/store/gateway-websocket';
|
import { ConnectionState } from '~/lib/websocket/gateway/types';
|
||||||
import { useTokenStore } from '~/store/token';
|
import { useGatewayStore } from '~/stores/gateway-store';
|
||||||
|
import { useTokenStore } from '~/stores/token-store';
|
||||||
|
|
||||||
export function GatewayWebSocketConnectionManager() {
|
export function GatewayWebSocketConnectionManager() {
|
||||||
const connectWebSocket = useGatewayWebSocketStore((state) => state.connect);
|
|
||||||
const disconnectWebSocket = useGatewayWebSocketStore((state) => state.disconnect);
|
|
||||||
const wsStatus = useGatewayWebSocketStore((state) => state.status);
|
|
||||||
|
|
||||||
const token = useTokenStore((state) =>
|
const token = useTokenStore((state) =>
|
||||||
state.token,
|
state.token,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const { setQueryClient } = useGatewayStore();
|
||||||
|
|
||||||
|
const queryClient = useQueryClient();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.debug(`WS Manager: Status (${wsStatus})`);
|
setQueryClient(queryClient);
|
||||||
|
}, [queryClient])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const { status, connect, disconnect } = useGatewayStore.getState();
|
||||||
|
|
||||||
if (!!token) {
|
if (!!token) {
|
||||||
// Connect if we should be connected and are currently idle, disconnected, or errored out
|
connect(token);
|
||||||
if (wsStatus === 'IDLE' || wsStatus === 'DISCONNECTED' || wsStatus === 'ERROR') {
|
|
||||||
console.log("WS Manager: Conditions met. Calling connect...");
|
|
||||||
// Pass the stable token getter function reference
|
|
||||||
connectWebSocket(() => token);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Disconnect if we shouldn't be connected and are currently in any connected/connecting state
|
if (status === ConnectionState.CONNECTED) {
|
||||||
if (wsStatus !== 'IDLE' && wsStatus !== 'DISCONNECTED') {
|
disconnect();
|
||||||
console.log("WS Manager: Conditions no longer met. Calling disconnect...");
|
|
||||||
disconnectWebSocket(true); // Intentional disconnect
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The disconnect logic for component unmount (e.g., user logs out entirely)
|
|
||||||
return () => {
|
return () => {
|
||||||
// Check status on unmount to avoid disconnecting if already idle/disconnected
|
if (status === ConnectionState.CONNECTED) {
|
||||||
const currentStatus = useGatewayWebSocketStore.getState().status;
|
disconnect();
|
||||||
if (currentStatus !== 'IDLE' && currentStatus !== 'DISCONNECTED') {
|
|
||||||
console.log("WS Manager: Unmounting. Calling disconnect...");
|
|
||||||
// Ensure Zustand has the latest state before calling disconnect
|
|
||||||
useGatewayWebSocketStore.getState().disconnect(true); // Intentional disconnect on unmount
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// Dependencies: connect/disconnect actions, auth status, route location
|
}, [token]);
|
||||||
}, [connectWebSocket, disconnectWebSocket]);
|
|
||||||
|
|
||||||
// This component doesn't render anything itself
|
return (
|
||||||
return null;
|
<>
|
||||||
|
{null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -1,185 +1,52 @@
|
|||||||
// ~/components/webrtc-connection-manager.tsx
|
import { useEffect, useRef } from "react";
|
||||||
import { useEffect, useRef } from 'react'; // Removed useState
|
import { ConnectionState } from "~/lib/websocket/voice/types";
|
||||||
import { useActiveVoiceChannelStore } from '~/store/active-voice-channel';
|
import { useGatewayStore } from "~/stores/gateway-store";
|
||||||
import { useGatewayWebSocketStore } from '~/store/gateway-websocket';
|
import { useVoiceStateStore } from "~/stores/voice-state-store";
|
||||||
import { useWebRTCStore } from '~/store/webrtc'; // Ensure WebRTCStatus is exported
|
import { useWebRTCStore } from "~/stores/webrtc-store";
|
||||||
|
|
||||||
export function WebRTCConnectionManager() {
|
export function WebRTCConnectionManager() {
|
||||||
console.log('WebRTC Manager: Mounting component.');
|
const gateway = useGatewayStore();
|
||||||
|
const voiceState = useVoiceStateStore();
|
||||||
|
const webrtc = useWebRTCStore();
|
||||||
|
|
||||||
const {
|
const remoteStream = useWebRTCStore(state => state.remoteStream);
|
||||||
serverId: activeServerId,
|
const audioRef = useRef<HTMLAudioElement>(null)
|
||||||
channelId: activeChannelId,
|
|
||||||
isVoiceActive,
|
|
||||||
} = useActiveVoiceChannelStore(state => ({
|
|
||||||
serverId: state.serverId,
|
|
||||||
channelId: state.channelId,
|
|
||||||
isVoiceActive: state.isVoiceActive,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const gatewayStatus = useGatewayWebSocketStore((state) => state.status);
|
if (audioRef.current) {
|
||||||
|
audioRef.current.srcObject = remoteStream
|
||||||
const {
|
}
|
||||||
joinVoiceChannel,
|
|
||||||
leaveVoiceChannel,
|
|
||||||
status: webRTCStatus,
|
|
||||||
currentChannelId: rtcCurrentChannelId,
|
|
||||||
} = useWebRTCStore((state) => ({
|
|
||||||
joinVoiceChannel: state.joinVoiceChannel,
|
|
||||||
leaveVoiceChannel: state.leaveVoiceChannel,
|
|
||||||
status: state.status,
|
|
||||||
currentChannelId: state.currentChannelId,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Use useRef for the stream to avoid re-triggering effect on set
|
|
||||||
const mediaStreamRef = useRef<MediaStream | null>(null);
|
|
||||||
// Use useRef for an operation lock to prevent re-entrancy
|
|
||||||
const operationLockRef = useRef<boolean>(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log('WebRTC Manager: Effect triggered', {
|
const unsubscribe = gateway.onVoiceServerUpdate(async (event) => {
|
||||||
activeServerId,
|
await webrtc.connect(event.token);
|
||||||
activeChannelId,
|
voiceState.joinVoiceChannel(event.serverId, event.channelId);
|
||||||
isVoiceActive,
|
|
||||||
gatewayStatus,
|
const stream = await navigator.mediaDevices.getUserMedia({
|
||||||
webRTCStatus,
|
audio: {
|
||||||
rtcCurrentChannelId,
|
noiseSuppression: false,
|
||||||
operationLock: operationLockRef.current,
|
},
|
||||||
|
video: false
|
||||||
});
|
});
|
||||||
|
|
||||||
const manageWebRTCConnection = async () => {
|
webrtc.createOffer(stream);
|
||||||
if (operationLockRef.current) {
|
});
|
||||||
console.debug('WebRTC Manager: Operation in progress, skipping.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isConnectedToSomeChannel =
|
|
||||||
webRTCStatus !== "IDLE" &&
|
|
||||||
webRTCStatus !== "DISCONNECTED" &&
|
|
||||||
webRTCStatus !== "FAILED";
|
|
||||||
|
|
||||||
// --- Condition to JOIN/SWITCH voice ---
|
|
||||||
if (isVoiceActive && activeServerId && activeChannelId && gatewayStatus === 'CONNECTED') {
|
|
||||||
// Condition 1: Not connected at all, and want to join.
|
|
||||||
// Condition 2: Connected to a DIFFERENT channel, and want to switch.
|
|
||||||
const needsToJoinOrSwitch =
|
|
||||||
!isConnectedToSomeChannel || (rtcCurrentChannelId !== activeChannelId);
|
|
||||||
|
|
||||||
if (needsToJoinOrSwitch) {
|
|
||||||
operationLockRef.current = true;
|
|
||||||
console.log(`WebRTC Manager: Attempting to join/switch to ${activeServerId}/${activeChannelId}. Current RTC status: ${webRTCStatus}, current RTC channel: ${rtcCurrentChannelId}`);
|
|
||||||
|
|
||||||
// If currently connected to a different channel, leave it first.
|
|
||||||
if (isConnectedToSomeChannel && rtcCurrentChannelId && rtcCurrentChannelId !== activeChannelId) {
|
|
||||||
console.log(`WebRTC Manager: Leaving current channel ${rtcCurrentChannelId} before switching.`);
|
|
||||||
leaveVoiceChannel();
|
|
||||||
// leaveVoiceChannel will change webRTCStatus, triggering this effect again.
|
|
||||||
// The operationLock will be released when status becomes IDLE/DISCONNECTED.
|
|
||||||
// No 'return' here needed, let the status change from leave drive the next step.
|
|
||||||
// The lock is set, so next iteration won't try to join immediately.
|
|
||||||
// It will fall through to the lock release logic when state becomes IDLE.
|
|
||||||
} else { // Not connected or switching from a null/same channel (should be IDLE then)
|
|
||||||
let streamToUse = mediaStreamRef.current;
|
|
||||||
|
|
||||||
// Acquire media if we don't have a usable stream
|
|
||||||
if (!streamToUse || streamToUse.getTracks().every(t => t.readyState === 'ended')) {
|
|
||||||
if (streamToUse) { // Clean up old ended stream
|
|
||||||
streamToUse.getTracks().forEach(track => track.stop());
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
console.log('WebRTC Manager: Acquiring new local media stream...');
|
|
||||||
streamToUse = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
|
|
||||||
mediaStreamRef.current = streamToUse;
|
|
||||||
} catch (err) {
|
|
||||||
console.error('WebRTC Manager: Failed to get user media:', err);
|
|
||||||
useWebRTCStore.setState({ status: "FAILED", lastError: 'Failed to get user media.' });
|
|
||||||
operationLockRef.current = false; // Release lock on failure
|
|
||||||
return; // Stop further processing for this run
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (streamToUse) {
|
|
||||||
console.log(`WebRTC Manager: Calling joinVoiceChannel for ${activeServerId}/${activeChannelId}`);
|
|
||||||
await joinVoiceChannel(activeServerId, activeChannelId, streamToUse);
|
|
||||||
// Don't release lock here immediately; let status changes from joinVoiceChannel
|
|
||||||
// (e.g., to CONNECTED or FAILED) handle releasing the lock.
|
|
||||||
} else {
|
|
||||||
console.error('WebRTC Manager: No media stream available to join channel.');
|
|
||||||
useWebRTCStore.setState({ status: "FAILED", lastError: 'Media stream unavailable.' });
|
|
||||||
operationLockRef.current = false; // Release lock
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// --- Condition to LEAVE voice ---
|
|
||||||
else { // Not (isVoiceActive && activeServerId && activeChannelId && gatewayStatus === 'CONNECTED')
|
|
||||||
if (isConnectedToSomeChannel) {
|
|
||||||
operationLockRef.current = true;
|
|
||||||
console.log('WebRTC Manager: Conditions met to leave active voice channel. Leaving...', { isVoiceActive, activeServerId, gatewayStatus, webRTCStatus });
|
|
||||||
leaveVoiceChannel();
|
|
||||||
// Lock will be released when status becomes IDLE/DISCONNECTED.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Manage operation lock based on final WebRTC state for this "cycle" ---
|
|
||||||
// This part is crucial: if an operation was started, the lock is only released
|
|
||||||
// when the WebRTC state settles into a terminal (IDLE, DISCONNECTED, FAILED) or successful (CONNECTED) state.
|
|
||||||
if (operationLockRef.current) { // Only if a lock was acquired in this effect run or previous
|
|
||||||
if (
|
|
||||||
webRTCStatus === "IDLE" ||
|
|
||||||
webRTCStatus === "DISCONNECTED" ||
|
|
||||||
webRTCStatus === "FAILED" ||
|
|
||||||
(webRTCStatus === "CONNECTED" && rtcCurrentChannelId === activeChannelId && isVoiceActive) // Successfully connected to desired channel
|
|
||||||
) {
|
|
||||||
// console.debug(`WebRTC Manager: Releasing operation lock. Status: ${webRTCStatus}`);
|
|
||||||
operationLockRef.current = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Release media stream if no longer needed ---
|
|
||||||
// This should happen if WebRTC is inactive AND user doesn't want voice.
|
|
||||||
if (
|
|
||||||
mediaStreamRef.current &&
|
|
||||||
(webRTCStatus === "IDLE" || webRTCStatus === "DISCONNECTED" || webRTCStatus === "FAILED") &&
|
|
||||||
!isVoiceActive // Only if voice is explicitly deactivated
|
|
||||||
) {
|
|
||||||
console.log('WebRTC Manager: Releasing local media stream as WebRTC is inactive and voice is not desired.');
|
|
||||||
mediaStreamRef.current.getTracks().forEach(track => track.stop());
|
|
||||||
mediaStreamRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
manageWebRTCConnection();
|
|
||||||
|
|
||||||
}, [
|
|
||||||
activeServerId,
|
|
||||||
activeChannelId,
|
|
||||||
isVoiceActive,
|
|
||||||
gatewayStatus,
|
|
||||||
webRTCStatus,
|
|
||||||
rtcCurrentChannelId,
|
|
||||||
joinVoiceChannel, // Stable from Zustand
|
|
||||||
leaveVoiceChannel, // Stable from Zustand
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Cleanup on component unmount
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
return () => {
|
||||||
console.log('WebRTC Manager: Unmounting component.');
|
voiceState.leaveVoiceChannel();
|
||||||
// Ensure we attempt to leave if connection is active
|
unsubscribe();
|
||||||
const { status: currentRtcStatus, leaveVoiceChannel: finalLeave } = useWebRTCStore.getState();
|
|
||||||
if (currentRtcStatus !== "IDLE" && currentRtcStatus !== "DISCONNECTED") {
|
|
||||||
console.log('WebRTC Manager: Unmounting. Leaving active voice channel.');
|
|
||||||
finalLeave();
|
|
||||||
}
|
|
||||||
// Stop any tracks held by the ref
|
|
||||||
if (mediaStreamRef.current) {
|
|
||||||
console.log('WebRTC Manager: Unmounting. Stopping tracks from mediaStreamRef.');
|
|
||||||
mediaStreamRef.current.getTracks().forEach(track => track.stop());
|
|
||||||
mediaStreamRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}, []); // Empty dependency array for unmount cleanup only
|
}, []);
|
||||||
|
|
||||||
return null;
|
useEffect(() => {
|
||||||
|
if (webrtc.status === ConnectionState.DISCONNECTED) {
|
||||||
|
voiceState.leaveVoiceChannel();
|
||||||
|
}
|
||||||
|
|
||||||
|
}, [webrtc.status]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<audio autoPlay ref={audioRef} className="hidden" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
103
app/components/message-box.tsx
Normal file
103
app/components/message-box.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { Plus, Send, Trash } from "lucide-react"
|
||||||
|
import React from "react"
|
||||||
|
import { sendMessage } from "~/lib/api/client/channel"
|
||||||
|
import { uploadFiles } from "~/lib/api/client/file"
|
||||||
|
import TextBox from "./custom-ui/text-box"
|
||||||
|
import { Button } from "./ui/button"
|
||||||
|
|
||||||
|
export interface MessageBoxProps {
|
||||||
|
channelId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MessageBox(
|
||||||
|
{ channelId }: MessageBoxProps
|
||||||
|
) {
|
||||||
|
const [text, setText] = React.useState("")
|
||||||
|
const [attachments, setAttachments] = React.useState<File[]>([])
|
||||||
|
|
||||||
|
const fileInputRef = React.useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
const onSendMessage = async () => {
|
||||||
|
const content = text.trim()
|
||||||
|
if (!content && !attachments.length)
|
||||||
|
return
|
||||||
|
|
||||||
|
const uploadedAttachments = await uploadFiles(attachments)
|
||||||
|
|
||||||
|
await sendMessage(channelId, text, uploadedAttachments)
|
||||||
|
setText("")
|
||||||
|
setAttachments([])
|
||||||
|
}
|
||||||
|
|
||||||
|
const addAttachment = async () => {
|
||||||
|
if (!fileInputRef.current)
|
||||||
|
return
|
||||||
|
|
||||||
|
fileInputRef.current.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = Array.from(e.target.files || [])
|
||||||
|
const newAttachments = [...attachments, ...files]
|
||||||
|
setAttachments(newAttachments.slice(0, 10))
|
||||||
|
|
||||||
|
if (!fileInputRef.current)
|
||||||
|
return
|
||||||
|
|
||||||
|
fileInputRef.current.value = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setText("")
|
||||||
|
setAttachments([])
|
||||||
|
}, [channelId])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-2">
|
||||||
|
<div className="max-h-1/2 w-full max-w-full grid items-center gap-x-2 outline-none py-4 px-2 rounded-xl no-scrollbar bg-background border border-input focus-within:ring-2 focus-within:ring-ring focus-within:border-ring"
|
||||||
|
style={{ gridTemplateColumns: "auto 1fr auto", gridTemplateRows: "1fr auto" }}>
|
||||||
|
<div className="row-start-1 col-start-1 row-span-2 col-span-1 h-full">
|
||||||
|
<Button size="icon" variant="ghost" onClick={addAttachment} disabled={attachments.length >= 10}>
|
||||||
|
<Plus />
|
||||||
|
</Button>
|
||||||
|
<input type="file" multiple className="hidden" ref={fileInputRef} onChange={onFileChange} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 row-start-1 col-start-2 row-span-1 col-span-1">
|
||||||
|
<TextBox value={text}
|
||||||
|
wrapperClassName="contain-inline-size"
|
||||||
|
onChange={setText}
|
||||||
|
placeholder="Type your message here..."
|
||||||
|
aria-label="Message input">
|
||||||
|
|
||||||
|
</TextBox>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 row-start-2 col-start-2 row-span-1 col-span-1 overflow-y-auto max-h-40">
|
||||||
|
<div className={`${attachments.length > 0 ? "pt-2" : "hidden"}`}>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{
|
||||||
|
attachments.map((file, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2 wrap-anywhere rounded-xl border border-input bg-background p-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
{file.name}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Button size="icon" variant="destructive" onClick={() => setAttachments(attachments.filter((_, j) => i !== j))}>
|
||||||
|
<Trash />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="row-start-1 col-start-3 row-span-2 col-span-1 h-full">
|
||||||
|
<Button size="icon" onClick={onSendMessage}>
|
||||||
|
<Send />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
109
app/components/modals/create-server-channel-modal.tsx
Normal file
109
app/components/modals/create-server-channel-modal.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { ChannelType } from "~/lib/api/types";
|
||||||
|
import { ModalType, useModalStore, type CreateServerChannelModalData } from "~/stores/modal-store";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../ui/dialog";
|
||||||
|
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "../ui/form";
|
||||||
|
import { Input } from "../ui/input";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select";
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
name: z.string().min(1).max(32),
|
||||||
|
type: z.nativeEnum(ChannelType),
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
export default function CreateServerChannelModal() {
|
||||||
|
const { type, data, isOpen, onClose } = useModalStore();
|
||||||
|
|
||||||
|
const isModalOpen = type === ModalType.CREATE_SERVER_CHANNEL && isOpen
|
||||||
|
|
||||||
|
let form = useForm<z.infer<typeof schema>>({
|
||||||
|
resolver: zodResolver(schema),
|
||||||
|
defaultValues: {
|
||||||
|
type: ChannelType.SERVER_TEXT
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const onOpenChange = () => {
|
||||||
|
form.reset()
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSubmit = async (values: z.infer<typeof schema>) => {
|
||||||
|
const response = await import("~/lib/api/client/server").then(m => m.default.createChannel((data as CreateServerChannelModalData['data']).serverId, values))
|
||||||
|
|
||||||
|
form.reset()
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isModalOpen} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create channel</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Give your channel a name and choose a channel type.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel required={!schema.shape.name.isOptional()}>Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="type"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel required={!schema.shape.type.isOptional()}>Type</FormLabel>
|
||||||
|
<Select defaultValue={field.value} onValueChange={field.onChange}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder="Select a channel type" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
{Object.entries({
|
||||||
|
[ChannelType.SERVER_TEXT]: "Text",
|
||||||
|
[ChannelType.SERVER_VOICE]: "Voice",
|
||||||
|
}).map(([type, label]) => (
|
||||||
|
<SelectItem key={type} value={type}>
|
||||||
|
{label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<DialogFooter className=" justify-between">
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button type="button" variant="secondary">
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||||
|
{form.formState.isSubmitting ? "Creating..." : "Create"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
66
app/components/modals/create-server-invite-modal.tsx
Normal file
66
app/components/modals/create-server-invite-modal.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { Check, Copy, RefreshCw } from "lucide-react";
|
||||||
|
import React from "react";
|
||||||
|
import { useOrigin } from "~/hooks/use-origin";
|
||||||
|
import { ModalType, useModalStore } from "~/stores/modal-store";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "../ui/dialog";
|
||||||
|
import { Input } from "../ui/input";
|
||||||
|
import { Label } from "../ui/label";
|
||||||
|
|
||||||
|
export default function CreateServerInviteModal() {
|
||||||
|
const { type, data, isOpen, onClose } = useModalStore();
|
||||||
|
const [inviteCode, setInviteCode] = React.useState<string | undefined>(undefined)
|
||||||
|
const origin = useOrigin()
|
||||||
|
const [isCopied, setCopied] = React.useState(false)
|
||||||
|
|
||||||
|
const isModalOpen = type === ModalType.CREATE_SERVER_INVITE && isOpen
|
||||||
|
const inviteLink = `${origin}/app/invite/${inviteCode}`
|
||||||
|
|
||||||
|
const onOpenChange = (openState: boolean) => {
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
const regenerateInviteCode = () => {
|
||||||
|
import("~/lib/api/client/server").then(m => m.default.createInvite((data as { serverId: string }).serverId)).then(invite => { setInviteCode(invite.code) })
|
||||||
|
}
|
||||||
|
|
||||||
|
const onCopy = () => {
|
||||||
|
navigator.clipboard.writeText(inviteLink)
|
||||||
|
setCopied(true)
|
||||||
|
|
||||||
|
setTimeout(() => setCopied(false), 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isModalOpen) {
|
||||||
|
import("~/lib/api/client/server").then(m => m.default.createInvite((data as { serverId: string }).serverId)).then(invite => { setInviteCode(invite.code) })
|
||||||
|
} else {
|
||||||
|
setInviteCode(undefined)
|
||||||
|
}
|
||||||
|
}, [isModalOpen])
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isModalOpen} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Invite your friends</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Label>Server invite link</Label>
|
||||||
|
<div className="flex items-center gap-x-2">
|
||||||
|
<Input value={inviteLink} readOnly />
|
||||||
|
<Button variant="ghost" size="icon" onClick={onCopy}>
|
||||||
|
{isCopied ? <Check className="size-4" /> : <Copy className="size-4" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="link" size="none" className="h-9 py-2" onClick={regenerateInviteCode}>
|
||||||
|
Generate a new invite
|
||||||
|
<RefreshCw />
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,59 +1,54 @@
|
|||||||
import { CirclePlus } from "lucide-react";
|
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { Button } from "~/components/ui/button";
|
import file from "~/lib/api/client/file";
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogClose,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "~/components/ui/dialog";
|
|
||||||
import server from "~/lib/api/client/server";
|
import server from "~/lib/api/client/server";
|
||||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "./ui/form";
|
import { ModalType, useModalStore } from "~/stores/modal-store";
|
||||||
import { IconUploadField } from "./ui/icon-upload-field";
|
import { Button } from "../ui/button";
|
||||||
import { Input } from "./ui/input";
|
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../ui/dialog";
|
||||||
|
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({
|
const schema = z.object({
|
||||||
name: z.string().min(1).max(32),
|
name: z.string().min(1).max(32),
|
||||||
icon: z.instanceof(File).optional(),
|
icon: z.instanceof(File).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export function CreateServerButton() {
|
|
||||||
const [open, setOpen] = React.useState(false);
|
export default function CreateServerModal() {
|
||||||
|
const { type, isOpen, onClose } = useModalStore();
|
||||||
|
|
||||||
|
const isModalOpen = type === ModalType.CREATE_SERVER && isOpen
|
||||||
|
|
||||||
let form = useForm<z.infer<typeof schema>>({
|
let form = useForm<z.infer<typeof schema>>({
|
||||||
resolver: zodResolver(schema),
|
resolver: zodResolver(schema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const onOpenChange = (openState: boolean) => {
|
||||||
|
form.reset()
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSubmit = async (values: z.infer<typeof schema>) => {
|
||||||
|
let iconId = undefined
|
||||||
|
if (values.icon) {
|
||||||
|
iconId = (await file.uploadFile(values.icon))[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await server.create({
|
||||||
|
name: values.name,
|
||||||
|
iconId
|
||||||
})
|
})
|
||||||
|
|
||||||
function onOpenChange(openState: boolean) {
|
|
||||||
setOpen(openState)
|
|
||||||
|
|
||||||
if (!openState) {
|
|
||||||
form.reset()
|
form.reset()
|
||||||
}
|
onClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onSubmit(values: z.infer<typeof schema>) {
|
|
||||||
const response = await server.create(values)
|
|
||||||
|
|
||||||
onOpenChange(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={isModalOpen} onOpenChange={onOpenChange}>
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button variant="secondary" size="none">
|
|
||||||
<CirclePlus className="size-8 m-2" />
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent className="sm:max-w-md">
|
<DialogContent className="sm:max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Create server</DialogTitle>
|
<DialogTitle>Create server</DialogTitle>
|
||||||
40
app/components/modals/delete-server-confirm-modal.tsx
Normal file
40
app/components/modals/delete-server-confirm-modal.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { ModalType, useModalStore } from "~/stores/modal-store";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../ui/dialog";
|
||||||
|
|
||||||
|
export default function DeleteServerConfirmModal() {
|
||||||
|
const { type, data, isOpen, onClose } = useModalStore();
|
||||||
|
|
||||||
|
const isModalOpen = type === ModalType.DELETE_SERVER_CONFIRM && isOpen
|
||||||
|
|
||||||
|
const onOpenChange = () => {
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onConfirm = async () => {
|
||||||
|
await import("~/lib/api/client/server").then(m => m.default.delet((data as { serverId: string }).serverId))
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isModalOpen} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Are you sure?</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
This action cannot be undone.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<DialogFooter className="justify-between">
|
||||||
|
<Button variant="default" onClick={onClose}>
|
||||||
|
No
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" onClick={onConfirm}>
|
||||||
|
Yes
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
112
app/components/modals/update-profile-modal.tsx
Normal file
112
app/components/modals/update-profile-modal.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { useShallow } from "zustand/react/shallow";
|
||||||
|
import file from "~/lib/api/client/file";
|
||||||
|
import { patchUser } from "~/lib/api/client/user";
|
||||||
|
import { ModalType, useModalStore } from "~/stores/modal-store";
|
||||||
|
import { useUsersStore } from "~/stores/users-store";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../ui/dialog";
|
||||||
|
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({
|
||||||
|
displayName: z.string().min(1).max(32).optional().nullable(),
|
||||||
|
avatar: z.instanceof(File).optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
export default function UpdateProfileModal() {
|
||||||
|
const { type, isOpen, onClose } = useModalStore();
|
||||||
|
const user = useUsersStore(useShallow(state => state.getCurrentUser()))
|
||||||
|
|
||||||
|
const isModalOpen = type === ModalType.UPDATE_PROFILE && isOpen
|
||||||
|
|
||||||
|
let form = useForm<z.infer<typeof schema>>({
|
||||||
|
resolver: zodResolver(schema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const onOpenChange = () => {
|
||||||
|
form.reset()
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSubmit = async (values: z.infer<typeof schema>) => {
|
||||||
|
if (!values)
|
||||||
|
return
|
||||||
|
|
||||||
|
let avatarId = undefined
|
||||||
|
if (values.avatar) {
|
||||||
|
avatarId = (await file.uploadFile(values.avatar))[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await patchUser({
|
||||||
|
displayName: values.displayName,
|
||||||
|
avatarId
|
||||||
|
})
|
||||||
|
|
||||||
|
form.reset()
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isModalOpen} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Update profile</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Update your profile.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="avatar"
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel required={!schema.shape.avatar.isOptional()}>Avatar</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<div className="flex flex-col items-center justify-center">
|
||||||
|
<IconUploadField
|
||||||
|
field={field}
|
||||||
|
error={fieldState.error}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="displayName"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel required={!schema.shape.displayName.isOptional()}>Display Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder={user?.displayName} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<DialogFooter className=" justify-between">
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button type="button" variant="secondary">
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||||
|
{form.formState.isSubmitting ? "Updating..." : "Update"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import { Check } from "lucide-react"
|
|
||||||
import { NavLink } from "react-router"
|
|
||||||
import type { RecipientChannel } from "~/lib/api/types"
|
|
||||||
import { cn } from "~/lib/utils"
|
|
||||||
import { useUserStore } from "~/store/user"
|
|
||||||
import { OnlineStatus } from "./online-status"
|
|
||||||
import { Avatar, AvatarImage } from "./ui/avatar"
|
|
||||||
import { Badge } from "./ui/badge"
|
|
||||||
|
|
||||||
interface PrivateChannelListItemProps {
|
|
||||||
channel: RecipientChannel
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PrivateChannelListItem({ channel }: PrivateChannelListItemProps) {
|
|
||||||
const userId = useUserStore(state => state.user?.id)
|
|
||||||
const recipients = channel.recipients.filter(recipient => recipient.id !== userId);
|
|
||||||
const renderSystemBadge = recipients.some(recipient => recipient.system) && recipients.length === 1
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<NavLink to={`/app/@me/channels/${channel.id}`} className={({ isActive }) =>
|
|
||||||
cn(
|
|
||||||
"rounded-xl justify-start",
|
|
||||||
isActive ? "bg-primary/20" : "bg-accent",
|
|
||||||
)
|
|
||||||
}>
|
|
||||||
<div className="flex items-center gap-2 max-w-72 p-2">
|
|
||||||
<div>
|
|
||||||
<OnlineStatus status="online">
|
|
||||||
<Avatar>
|
|
||||||
<AvatarImage src={`https://api.dicebear.com/9.x/bottts/jpg?seed=${channel.name}`} />
|
|
||||||
</Avatar>
|
|
||||||
</OnlineStatus>
|
|
||||||
</div>
|
|
||||||
<div className="truncate">
|
|
||||||
{recipients.map(recipient => recipient.displayName || recipient.username).join(", ")}
|
|
||||||
</div>
|
|
||||||
{renderSystemBadge && <Badge variant="default"> <Check /> System</Badge>}
|
|
||||||
</div>
|
|
||||||
</NavLink>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
28
app/components/providers/modal-provider.tsx
Normal file
28
app/components/providers/modal-provider.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import React from "react";
|
||||||
|
import CreateServerChannelModal from "../modals/create-server-channel-modal";
|
||||||
|
import CreateServerInviteModal from "../modals/create-server-invite-modal";
|
||||||
|
import CreateServerModal from "../modals/create-server-modal";
|
||||||
|
import DeleteServerConfirmModal from "../modals/delete-server-confirm-modal";
|
||||||
|
import UpdateProfileModal from "../modals/update-profile-modal";
|
||||||
|
|
||||||
|
export default function ModalProvider() {
|
||||||
|
const [isMounted, setIsMounted] = React.useState(false);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setIsMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!isMounted) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<CreateServerModal />
|
||||||
|
<CreateServerChannelModal />
|
||||||
|
<CreateServerInviteModal />
|
||||||
|
<DeleteServerConfirmModal />
|
||||||
|
<UpdateProfileModal />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createContext, useContext, useEffect, useState } from "react"
|
import { createContext, useContext, useEffect, useState } from "react"
|
||||||
|
|
||||||
type Theme = "dark" | "light" | "system"
|
export type Theme = "dark" | "light" | "system"
|
||||||
|
|
||||||
type ThemeProviderProps = {
|
type ThemeProviderProps = {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
import { Moon, Sun } from "lucide-react"
|
import { Moon, Sun } from "lucide-react"
|
||||||
|
|
||||||
import { useTheme } from "~/components/theme/theme-provider"
|
import { useTheme, type Theme } from "~/components/theme/theme-provider"
|
||||||
import { Button } from "~/components/ui/button"
|
import { Button } from "~/components/ui/button"
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuRadioGroup,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuTrigger
|
||||||
} from "~/components/ui/dropdown-menu"
|
} from "~/components/ui/dropdown-menu"
|
||||||
|
|
||||||
export function ThemeToggle() {
|
export function ThemeToggle() {
|
||||||
const { setTheme } = useTheme()
|
const { theme, setTheme } = useTheme()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
@@ -21,16 +22,18 @@ export function ThemeToggle() {
|
|||||||
<span className="sr-only">Toggle theme</span>
|
<span className="sr-only">Toggle theme</span>
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent>
|
||||||
<DropdownMenuItem onClick={() => setTheme("light")}>
|
<DropdownMenuRadioGroup value={theme} onValueChange={(value) => setTheme(value as Theme)}>
|
||||||
|
<DropdownMenuRadioItem value="light">
|
||||||
Light
|
Light
|
||||||
</DropdownMenuItem>
|
</DropdownMenuRadioItem>
|
||||||
<DropdownMenuItem onClick={() => setTheme("dark")}>
|
<DropdownMenuRadioItem value="dark">
|
||||||
Dark
|
Dark
|
||||||
</DropdownMenuItem>
|
</DropdownMenuRadioItem>
|
||||||
<DropdownMenuItem onClick={() => setTheme("system")}>
|
<DropdownMenuRadioItem value="system">
|
||||||
System
|
System
|
||||||
</DropdownMenuItem>
|
</DropdownMenuRadioItem>
|
||||||
|
</DropdownMenuRadioGroup>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
)
|
)
|
||||||
|
|||||||
9
app/components/ui/aspect-ratio.tsx
Normal file
9
app/components/ui/aspect-ratio.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
|
||||||
|
|
||||||
|
function AspectRatio({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AspectRatioPrimitive.Root>) {
|
||||||
|
return <AspectRatioPrimitive.Root data-slot="aspect-ratio" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export { AspectRatio }
|
||||||
@@ -7,9 +7,11 @@ function ScrollArea({
|
|||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
scrollbarSize,
|
scrollbarSize,
|
||||||
|
viewportRef,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root> & {
|
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root> & {
|
||||||
scrollbarSize?: "default" | "narrow" | "none"
|
scrollbarSize?: "default" | "narrow" | "none",
|
||||||
|
viewportRef?: React.Ref<HTMLDivElement>
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<ScrollAreaPrimitive.Root
|
<ScrollAreaPrimitive.Root
|
||||||
@@ -18,6 +20,7 @@ function ScrollArea({
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<ScrollAreaPrimitive.Viewport
|
<ScrollAreaPrimitive.Viewport
|
||||||
|
ref={viewportRef}
|
||||||
data-slot="scroll-area-viewport"
|
data-slot="scroll-area-viewport"
|
||||||
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
|
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
|
||||||
>
|
>
|
||||||
|
|||||||
183
app/components/ui/select.tsx
Normal file
183
app/components/ui/select.tsx
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
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<typeof SelectPrimitive.Root>) {
|
||||||
|
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||||
|
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectValue({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||||
|
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectTrigger({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||||
|
size?: "sm" | "default"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
data-slot="select-trigger"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDownIcon className="size-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
position = "popper",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
data-slot="select-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||||
|
position === "popper" &&
|
||||||
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
"p-1",
|
||||||
|
position === "popper" &&
|
||||||
|
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
data-slot="select-label"
|
||||||
|
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
data-slot="select-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
data-slot="select-separator"
|
||||||
|
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollUpButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
data-slot="select-scroll-up-button"
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUpIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollDownButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
data-slot="select-scroll-down-button"
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDownIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
}
|
||||||
19
app/components/user-avatar.tsx
Normal file
19
app/components/user-avatar.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import type { PartialUser } from "~/lib/api/types"
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar"
|
||||||
|
|
||||||
|
interface UserAvatarProps {
|
||||||
|
user: PartialUser | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UserAvatar(
|
||||||
|
{ user, ...props }: UserAvatarProps & React.ComponentProps<typeof Avatar>
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<Avatar {...props}>
|
||||||
|
<AvatarImage src={user?.avatarUrl} />
|
||||||
|
<AvatarFallback className="text-muted-foreground">
|
||||||
|
{user?.username?.[0]}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
)
|
||||||
|
}
|
||||||
102
app/components/visible-trigger.tsx
Normal file
102
app/components/visible-trigger.tsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
interface VisibleTriggerProps {
|
||||||
|
onVisible: () => void | Promise<void>;
|
||||||
|
options?: IntersectionObserverInit;
|
||||||
|
triggerOnce?: boolean;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A component that calls a function when it becomes visible in the viewport
|
||||||
|
* or a specified scrollable container.
|
||||||
|
*/
|
||||||
|
export default function VisibleTrigger({
|
||||||
|
onVisible, // Function to call when the element becomes visible
|
||||||
|
options = {}, // Optional: IntersectionObserver options (root, rootMargin, threshold)
|
||||||
|
triggerOnce = true, // Optional: If true, trigger only the first time it becomes visible
|
||||||
|
children,
|
||||||
|
style,
|
||||||
|
...props
|
||||||
|
}: VisibleTriggerProps & React.ComponentProps<'div'>) {
|
||||||
|
const elementRef = useRef(null); // Ref to attach to the DOM element we want to observe
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const element = elementRef.current;
|
||||||
|
|
||||||
|
// Only proceed if we have the DOM element and the function to call
|
||||||
|
if (!element) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default IntersectionObserver options
|
||||||
|
const defaultOptions = {
|
||||||
|
root: null, // default is the viewport
|
||||||
|
rootMargin: '0px', // No margin by default
|
||||||
|
threshold: 0, // Trigger as soon as any part of the element is visible
|
||||||
|
};
|
||||||
|
|
||||||
|
// Merge provided options with defaults
|
||||||
|
const observerOptions = { ...defaultOptions, ...options };
|
||||||
|
|
||||||
|
// Create the Intersection Observer instance
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
const entry = entries[0]; // Assuming only one target element
|
||||||
|
|
||||||
|
// If the element is intersecting (visible)...
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
// console.log('VisibleTrigger: Element is intersecting.', entry);
|
||||||
|
|
||||||
|
// Call the provided function
|
||||||
|
onVisible();
|
||||||
|
|
||||||
|
// If triggerOnce is true, stop observing this element immediately
|
||||||
|
if (triggerOnce) {
|
||||||
|
// console.log('VisibleTrigger: Triggered once, disconnecting observer.');
|
||||||
|
observer.disconnect(); // Disconnect stops all observations by this instance
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// console.log('VisibleTrigger: Element is NOT intersecting.', entry);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
observerOptions // Pass the options to the observer
|
||||||
|
);
|
||||||
|
|
||||||
|
// Start observing the element
|
||||||
|
// console.log('VisibleTrigger: Starting observation.', element);
|
||||||
|
observer.observe(element);
|
||||||
|
|
||||||
|
// Cleanup function: Disconnect the observer when the component unmounts
|
||||||
|
// or when the effect dependencies change.
|
||||||
|
return () => {
|
||||||
|
// console.log('VisibleTrigger: Cleaning up observer.');
|
||||||
|
if (observer) {
|
||||||
|
// Calling disconnect multiple times is safe.
|
||||||
|
observer.disconnect();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Effect dependencies:
|
||||||
|
// - elementRef: Need the DOM element reference.
|
||||||
|
// - onVisible: If the function prop changes, we need a new observer with the new function.
|
||||||
|
// - options: If observer options change, we need a new observer.
|
||||||
|
// - triggerOnce: If triggerOnce changes, the logic inside the observer callback changes,
|
||||||
|
// so we need a new observer instance.
|
||||||
|
}, [elementRef, onVisible, options, triggerOnce]);
|
||||||
|
|
||||||
|
// Render a div that we will attach the ref to.
|
||||||
|
// Ensure it has some minimal dimension if no children are provided,
|
||||||
|
// so the observer can detect its presence.
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={elementRef}
|
||||||
|
style={{ minHeight: children ? 'auto' : '1px', ...style }}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children} {/* Render any children passed to the component */}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
17
app/hooks/use-origin.ts
Normal file
17
app/hooks/use-origin.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
export const useOrigin = () => {
|
||||||
|
const [isMounted, setIsMounted] = React.useState(false);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setIsMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const origin = typeof window !== 'undefined' && window.location.origin ? window.location.origin : '';
|
||||||
|
|
||||||
|
if (!isMounted) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return origin;
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import axios from "../http-client"
|
import axios from "../http-client"
|
||||||
import type { User } from "../types"
|
import type { FullUser } from "../types"
|
||||||
|
|
||||||
interface RegisterRequest {
|
interface RegisterRequest {
|
||||||
email: string
|
email: string
|
||||||
@@ -14,7 +14,7 @@ interface LoginRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface LoginResponse {
|
interface LoginResponse {
|
||||||
user: User
|
user: FullUser
|
||||||
token: string
|
token: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
35
app/lib/api/client/channel.ts
Normal file
35
app/lib/api/client/channel.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import axios from "../http-client";
|
||||||
|
import { messageSchema, type ChannelId, type Uuid } from "../types";
|
||||||
|
|
||||||
|
export async function paginatedMessages(
|
||||||
|
channelId: ChannelId,
|
||||||
|
limit: number,
|
||||||
|
before: ChannelId | undefined,
|
||||||
|
) {
|
||||||
|
const response = await axios.get(`/channels/${channelId}/messages`, {
|
||||||
|
params: {
|
||||||
|
limit,
|
||||||
|
before,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return (response.data as any[]).map((value, _) => messageSchema.parse(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendMessage(
|
||||||
|
channelId: ChannelId,
|
||||||
|
content: string,
|
||||||
|
attachments?: Uuid[]
|
||||||
|
) {
|
||||||
|
const response = await axios.post(`/channels/${channelId}/messages`, {
|
||||||
|
content,
|
||||||
|
attachments,
|
||||||
|
})
|
||||||
|
|
||||||
|
return messageSchema.parse(response.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
paginatedMessages,
|
||||||
|
sendMessage,
|
||||||
|
}
|
||||||
26
app/lib/api/client/file.ts
Normal file
26
app/lib/api/client/file.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import axios from "../http-client"
|
||||||
|
|
||||||
|
export async function uploadFile(file: File) {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append("files", file)
|
||||||
|
|
||||||
|
const response = await axios.postForm(`/files`, formData)
|
||||||
|
|
||||||
|
return response.data as string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadFiles(file: File[]) {
|
||||||
|
const formData = new FormData()
|
||||||
|
for (const f of file) {
|
||||||
|
formData.append("files", f)
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await axios.postForm(`/files`, formData)
|
||||||
|
|
||||||
|
return response.data as string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
uploadFile,
|
||||||
|
uploadFiles
|
||||||
|
}
|
||||||
@@ -1,15 +1,14 @@
|
|||||||
import axios from "../http-client"
|
import axios from "../http-client"
|
||||||
import type { Server } from "../types"
|
import type { ChannelId, ChannelType, Server, ServerChannel, ServerId, ServerInvite } from "../types"
|
||||||
|
|
||||||
interface CreateServerRequest {
|
interface CreateServerRequest {
|
||||||
name: string
|
name: string
|
||||||
icon?: File
|
iconId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function create(request: CreateServerRequest) {
|
interface CreateServerChannelRequest {
|
||||||
const response = await axios.postForm("/servers", request)
|
name: string
|
||||||
|
type: ChannelType
|
||||||
return response.data as Server
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function list() {
|
export async function list() {
|
||||||
@@ -18,7 +17,69 @@ export async function list() {
|
|||||||
return response.data as Server[]
|
return response.data as Server[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function create(request: CreateServerRequest) {
|
||||||
|
const response = await axios.post("/servers", request)
|
||||||
|
|
||||||
|
return response.data as Server
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function get(serverId: ServerId) {
|
||||||
|
const response = await axios.get(`/servers/${serverId}`)
|
||||||
|
|
||||||
|
return response.data as Server
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function delet(serverId: ServerId) {
|
||||||
|
const response = await axios.delete(`/servers/${serverId}`)
|
||||||
|
|
||||||
|
return response.data as Server
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listChannels(serverId: ServerId) {
|
||||||
|
const response = await axios.get(`/servers/${serverId}/channels`)
|
||||||
|
|
||||||
|
return response.data as ServerChannel[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createChannel(serverId: ServerId, request: CreateServerChannelRequest) {
|
||||||
|
const response = await axios.post(`/servers/${serverId}/channels`, request)
|
||||||
|
|
||||||
|
return response.data as ServerChannel
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getChannel(serverId: ServerId, channelId: ChannelId) {
|
||||||
|
const response = await axios.get(`/servers/${serverId}/channels/${channelId}`)
|
||||||
|
|
||||||
|
return response.data as ServerChannel
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteChannel(serverId: ServerId, channelId: ChannelId) {
|
||||||
|
const response = await axios.delete(`/servers/${serverId}/channels/${channelId}`)
|
||||||
|
|
||||||
|
return response.data as ServerChannel
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createInvite(serverId: ServerId) {
|
||||||
|
const response = await axios.post(`/servers/${serverId}/invites`)
|
||||||
|
|
||||||
|
return response.data as ServerInvite
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getInvite(inviteCode: string) {
|
||||||
|
const response = await axios.get(`/invites/${inviteCode}`)
|
||||||
|
|
||||||
|
return response.data as Server
|
||||||
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
create,
|
create,
|
||||||
list,
|
list,
|
||||||
|
listChannels,
|
||||||
|
get,
|
||||||
|
delet,
|
||||||
|
createChannel,
|
||||||
|
getChannel,
|
||||||
|
deleteChannel,
|
||||||
|
createInvite,
|
||||||
|
getInvite
|
||||||
}
|
}
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import axios from "../http-client"
|
|
||||||
import type { Uuid } from "../types"
|
|
||||||
|
|
||||||
export async function test(channel_id: Uuid, sdp: RTCSessionDescriptionInit) {
|
|
||||||
const response = await axios.post(`/voice/${channel_id}/connect`, {
|
|
||||||
sdp: sdp
|
|
||||||
})
|
|
||||||
|
|
||||||
return response.data.sdp as RTCSessionDescriptionInit
|
|
||||||
}
|
|
||||||
|
|
||||||
export default {
|
|
||||||
test
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,16 @@
|
|||||||
import axios from "../http-client"
|
import axios from "../http-client"
|
||||||
import type { RecipientChannel, User } from "../types"
|
import type { FullUser, PartialUser, RecipientChannel, UserId, Uuid } from "../types"
|
||||||
|
|
||||||
export async function me() {
|
export async function me() {
|
||||||
const response = await axios.get("/users/@me")
|
const response = await axios.get("/users/@me")
|
||||||
|
|
||||||
return response.data as User
|
return response.data as FullUser
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUser(userId: UserId) {
|
||||||
|
const response = await axios.get(`/users/${userId}`)
|
||||||
|
|
||||||
|
return response.data as PartialUser
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function channels() {
|
export async function channels() {
|
||||||
@@ -13,7 +19,20 @@ export async function channels() {
|
|||||||
return response.data as RecipientChannel[]
|
return response.data as RecipientChannel[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PatchUserRequest {
|
||||||
|
displayName?: string | null
|
||||||
|
avatarId?: Uuid | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function patchUser(request: PatchUserRequest) {
|
||||||
|
const response = await axios.patch(`/users/@me`, request)
|
||||||
|
|
||||||
|
return response.data as FullUser
|
||||||
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
me,
|
me,
|
||||||
channels
|
channels,
|
||||||
|
getUser,
|
||||||
|
patchUser
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import axios from "axios"
|
import axios from "axios"
|
||||||
import { useTokenStore } from "~/store/token"
|
import { useTokenStore } from "~/stores/token-store"
|
||||||
import { API_URL } from "../consts"
|
import { API_URL } from "../consts"
|
||||||
|
|
||||||
axios.interceptors.request.use(
|
axios.interceptors.request.use(
|
||||||
|
|||||||
@@ -1,7 +1,45 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export type TypeToZod<T> = {
|
||||||
|
[K in keyof T]:
|
||||||
|
// 1. Handle Arrays (including arrays of objects, optional or required)
|
||||||
|
T[K] extends ReadonlyArray<infer E> | undefined
|
||||||
|
? undefined extends T[K]
|
||||||
|
? E extends object
|
||||||
|
? z.ZodOptional<z.ZodArray<z.ZodObject<TypeToZod<E>>>>
|
||||||
|
: z.ZodOptional<z.ZodArray<z.ZodType<Exclude<E, null | undefined>>>>
|
||||||
|
: E extends object
|
||||||
|
? z.ZodArray<z.ZodObject<TypeToZod<E>>>
|
||||||
|
: z.ZodArray<z.ZodType<Exclude<E, null | undefined>>>
|
||||||
|
// 2. Handle Primitives
|
||||||
|
: T[K] extends string | number | boolean | Date | null | undefined
|
||||||
|
? undefined extends T[K]
|
||||||
|
? z.ZodOptional<z.ZodType<Exclude<T[K], undefined | null>>>
|
||||||
|
: z.ZodType<T[K]>
|
||||||
|
// 3. Handle Objects (required or optional, but not arrays)
|
||||||
|
: T[K] extends object | undefined
|
||||||
|
? undefined extends T[K]
|
||||||
|
? z.ZodOptional<z.ZodObject<TypeToZod<NonNullable<T[K]>>>>
|
||||||
|
: T[K] extends object
|
||||||
|
? z.ZodObject<TypeToZod<T[K]>>
|
||||||
|
: z.ZodUnknown // Fallback for unexpected required non-object/non-primitive types
|
||||||
|
// 4. Fallback for any other types
|
||||||
|
: z.ZodUnknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createZodObject = <T>(obj: TypeToZod<T>) => {
|
||||||
|
return z.object(obj);
|
||||||
|
};
|
||||||
|
|
||||||
export type Uuid = string
|
export type Uuid = string
|
||||||
|
|
||||||
export interface User {
|
export type UserId = Uuid
|
||||||
id: Uuid
|
export type ServerId = Uuid
|
||||||
|
export type ChannelId = Uuid
|
||||||
|
export type MessageId = Uuid
|
||||||
|
|
||||||
|
export interface FullUser {
|
||||||
|
id: UserId
|
||||||
avatarUrl?: string
|
avatarUrl?: string
|
||||||
username: string
|
username: string
|
||||||
displayName?: string
|
displayName?: string
|
||||||
@@ -12,22 +50,88 @@ export interface User {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Server {
|
export interface Server {
|
||||||
id: Uuid
|
id: ServerId
|
||||||
name: string
|
name: string
|
||||||
icon_url?: string
|
iconUrl?: string
|
||||||
owner: Uuid
|
ownerId: UserId
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ChannelType {
|
||||||
|
SERVER_TEXT = 'server_text',
|
||||||
|
SERVER_VOICE = 'server_voice',
|
||||||
|
SERVER_CATEGORY = 'server_category',
|
||||||
|
|
||||||
|
DIRECT_MESSAGE = 'direct_message',
|
||||||
|
GROUP = 'group',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Message {
|
||||||
|
id: MessageId
|
||||||
|
channelId: ChannelId
|
||||||
|
authorId: UserId
|
||||||
|
content: string
|
||||||
|
createdAt: Date
|
||||||
|
attachments: UploadedFile[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadedFile {
|
||||||
|
id: Uuid
|
||||||
|
filename: string
|
||||||
|
contentType: string
|
||||||
|
size: number
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const uploadFileSchema = createZodObject<UploadedFile>({
|
||||||
|
id: z.string(),
|
||||||
|
filename: z.string(),
|
||||||
|
contentType: z.string(),
|
||||||
|
size: z.number(),
|
||||||
|
url: z.string(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const messageSchema = createZodObject<Message>({
|
||||||
|
id: z.string(),
|
||||||
|
channelId: z.string(),
|
||||||
|
authorId: z.string(),
|
||||||
|
content: z.string(),
|
||||||
|
createdAt: z.coerce.date(),
|
||||||
|
attachments: z.array(uploadFileSchema),
|
||||||
|
})
|
||||||
|
|
||||||
|
export interface Channel {
|
||||||
|
id: ChannelId
|
||||||
|
name: string
|
||||||
|
type: ChannelType
|
||||||
|
lastMessageId?: MessageId
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServerChannel {
|
||||||
|
id: ChannelId
|
||||||
|
name: string
|
||||||
|
type: ChannelType
|
||||||
|
lastMessageId?: MessageId
|
||||||
|
serverId: ServerId
|
||||||
|
parentId?: ChannelId
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServerInvite {
|
||||||
|
code: string
|
||||||
|
serverId: ServerId
|
||||||
|
inviterId?: UserId
|
||||||
|
expiresAt?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RecipientChannel {
|
export interface RecipientChannel {
|
||||||
id: Uuid
|
id: ChannelId
|
||||||
name: string
|
name: string
|
||||||
type: string
|
type: ChannelType
|
||||||
lastMessageId?: Uuid
|
lastMessageId?: MessageId
|
||||||
recipients: PartialUser[]
|
recipients: PartialUser[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PartialUser {
|
export interface PartialUser {
|
||||||
id: Uuid
|
id: ChannelId
|
||||||
username: string
|
username: string
|
||||||
displayName?: string
|
displayName?: string
|
||||||
avatarUrl?: string,
|
avatarUrl?: string,
|
||||||
|
|||||||
@@ -12,3 +12,32 @@ export function getFirstLetters(str: string, n: number): string {
|
|||||||
.map(word => word[0] || '')
|
.map(word => word[0] || '')
|
||||||
.join('');
|
.join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatFileSize(bytes: number, decimals = 2): string {
|
||||||
|
if (bytes === 0) return '0 Bytes';
|
||||||
|
const k = 1024;
|
||||||
|
const dm = decimals < 0 ? 0 : decimals;
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPrefixedLogger(prefix: string, styles?: string[]) {
|
||||||
|
const result: Record<string, (...args: any[]) => void> = {};
|
||||||
|
|
||||||
|
const methods = ['log', 'trace', 'debug', 'info', 'warn', 'error'] as const;
|
||||||
|
|
||||||
|
for (const methodName of methods) {
|
||||||
|
const originalMethod = console[methodName].bind(console);
|
||||||
|
|
||||||
|
result[methodName] = (...args: any[]) => {
|
||||||
|
if (typeof args[0] === 'string') {
|
||||||
|
originalMethod(`${prefix} ${args[0]}`, ...(styles || []), ...args.slice(1));
|
||||||
|
} else {
|
||||||
|
originalMethod(prefix, styles, ...args);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
// ~/lib/websocket/types.ts
|
|
||||||
|
|
||||||
import type { Server, Uuid } from "../api/types";
|
|
||||||
|
|
||||||
export type WebSocketStatus =
|
|
||||||
| "IDLE" // Initial state, not connected
|
|
||||||
| "CONNECTING" // Attempting to connect
|
|
||||||
| "AUTHENTICATING"// Connection open, sending/awaiting auth
|
|
||||||
| "CONNECTED" // Authenticated and operational
|
|
||||||
| "DISCONNECTED" // User or clean disconnect, no auto-reconnect
|
|
||||||
| "RECONNECTING" // Attempting to reconnect after an unexpected disconnect
|
|
||||||
| "ERROR"; // Error state, usually after server reports an error, no auto-reconnect
|
|
||||||
|
|
||||||
// --- Client to Server Messages ---
|
|
||||||
interface AuthenticateClientPayload {
|
|
||||||
token: string;
|
|
||||||
}
|
|
||||||
interface AuthenticateClientMessage {
|
|
||||||
type: "AUTHENTICATE";
|
|
||||||
data: AuthenticateClientPayload;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface VoiceStateUpdateClientPayload {
|
|
||||||
serverId: Uuid;
|
|
||||||
channelId: Uuid;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface VoiceStateUpdateClientMessage {
|
|
||||||
type: "VOICE_STATE_UPDATE";
|
|
||||||
data: VoiceStateUpdateClientPayload;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ClientMessage =
|
|
||||||
| AuthenticateClientMessage
|
|
||||||
| VoiceStateUpdateClientMessage;
|
|
||||||
|
|
||||||
// --- Server to Client Messages ---
|
|
||||||
|
|
||||||
interface AuthenticateAcceptedServerPayload {
|
|
||||||
userId: Uuid; // Or number, depending on your entity::user::Id serialization
|
|
||||||
sessionKey: string; // A unique identifier for the user's session
|
|
||||||
}
|
|
||||||
export interface AuthenticateAcceptedServerMessage { // Export if used directly
|
|
||||||
type: "AUTHENTICATE_ACCEPTED";
|
|
||||||
data: AuthenticateAcceptedServerPayload;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AuthenticateDeniedServerMessage { // Export if used directly
|
|
||||||
type: "AUTHENTICATE_DENIED";
|
|
||||||
// No data for this message
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AddServerEvent = { type: 'ADD_SERVER'; data: { server: Server } };
|
|
||||||
export type RemoveServerEvent = { type: 'REMOVE_SERVER'; data: { serverId: Uuid } };
|
|
||||||
export type VoiceServerUpdateServerEvent = { type: 'VOICE_SERVER_UPDATE'; data: { serverId: Uuid, channelId: Uuid, token: string } };
|
|
||||||
|
|
||||||
// Union of all your specific business events
|
|
||||||
export type EventServerPayload = AddServerEvent | RemoveServerEvent | VoiceServerUpdateServerEvent; // Add other events here
|
|
||||||
|
|
||||||
interface EventServerMessage {
|
|
||||||
type: "EVENT";
|
|
||||||
data: {
|
|
||||||
event: EventServerPayload; // The actual event object
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ErrorServerPayload {
|
|
||||||
code: string;
|
|
||||||
}
|
|
||||||
export interface ErrorServerMessage { // Export if used directly
|
|
||||||
type: "ERROR";
|
|
||||||
data: ErrorServerPayload;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ServerMessage =
|
|
||||||
| AuthenticateAcceptedServerMessage
|
|
||||||
| AuthenticateDeniedServerMessage
|
|
||||||
| EventServerMessage
|
|
||||||
| ErrorServerMessage;
|
|
||||||
308
app/lib/websocket/gateway/client.ts
Normal file
308
app/lib/websocket/gateway/client.ts
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
import type { ChannelId, ServerId, UserId } from '~/lib/api/types';
|
||||||
|
import { createPrefixedLogger } from '~/lib/utils';
|
||||||
|
import {
|
||||||
|
type ClientMessage,
|
||||||
|
ClientMessageType,
|
||||||
|
ConnectionState,
|
||||||
|
ErrorCode,
|
||||||
|
type EventData,
|
||||||
|
EventType,
|
||||||
|
type ServerMessage,
|
||||||
|
ServerMessageType
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
export type GatewayEvents = {
|
||||||
|
[K in EventType]: (data: Extract<EventData, { type: K }>['data']) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ControlEvents = {
|
||||||
|
stateChange: (state: ConnectionState) => void;
|
||||||
|
error: (error: Error, code?: ErrorCode) => void;
|
||||||
|
authenticated: (userId: UserId, sessionKey: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface GatewayClientOptions {
|
||||||
|
reconnect?: boolean;
|
||||||
|
reconnectDelay?: number;
|
||||||
|
maxReconnectAttempts?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GatewayClient {
|
||||||
|
private socket: WebSocket | null = null;
|
||||||
|
private state: ConnectionState = ConnectionState.DISCONNECTED;
|
||||||
|
private url: string;
|
||||||
|
private token: string | null = null;
|
||||||
|
private sessionKey: string | null = null;
|
||||||
|
private userId: string | null = null;
|
||||||
|
private reconnectAttempts = 0;
|
||||||
|
private reconnectTimeout: NodeJS.Timeout | null = null;
|
||||||
|
private eventHandlers: Partial<ControlEvents> = {};
|
||||||
|
private serverEventHandlers: Partial<GatewayEvents> = {};
|
||||||
|
private options: Required<GatewayClientOptions>;
|
||||||
|
private closeInitiatedByClient = false;
|
||||||
|
|
||||||
|
private connectionLock = false;
|
||||||
|
|
||||||
|
constructor(url: string, options: GatewayClientOptions = {}) {
|
||||||
|
this.url = url;
|
||||||
|
this.options = {
|
||||||
|
reconnect: options.reconnect ?? true,
|
||||||
|
reconnectDelay: options.reconnectDelay ?? 5000,
|
||||||
|
maxReconnectAttempts: options.maxReconnectAttempts ?? 10
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public methods
|
||||||
|
public connect(token: string): void {
|
||||||
|
logger.log('Connecting to %s', this.url);
|
||||||
|
|
||||||
|
if (this.connectionLock) {
|
||||||
|
logger.warn('Connection already in progress');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.token === token) {
|
||||||
|
logger.warn('Token is the same as the current token');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.connectionLock = true;
|
||||||
|
|
||||||
|
if (this.state !== ConnectionState.DISCONNECTED) {
|
||||||
|
this.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.token = token;
|
||||||
|
this.closeInitiatedByClient = false;
|
||||||
|
this.reconnectAttempts = 0;
|
||||||
|
this.connectToWebSocket();
|
||||||
|
}
|
||||||
|
|
||||||
|
public disconnect(): void {
|
||||||
|
logger.log('Disconnecting');
|
||||||
|
|
||||||
|
this.closeInitiatedByClient = true;
|
||||||
|
this.cleanupSocket();
|
||||||
|
this.setState(ConnectionState.DISCONNECTED);
|
||||||
|
|
||||||
|
this.connectionLock = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public updateVoiceState(serverId: ServerId, channelId: ChannelId): void {
|
||||||
|
this.sendMessage({
|
||||||
|
type: ClientMessageType.VOICE_STATE_UPDATE,
|
||||||
|
data: { serverId, channelId }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public requestVoiceStates(serverId: ServerId): void {
|
||||||
|
this.sendMessage({
|
||||||
|
type: ClientMessageType.REQUEST_VOICE_STATES,
|
||||||
|
data: { serverId }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public onEvent<K extends keyof GatewayEvents>(event: K | string, handler: GatewayEvents[K]): void {
|
||||||
|
this.serverEventHandlers[event as K] = handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
public offEvent<K extends keyof GatewayEvents>(event: K): void {
|
||||||
|
delete this.serverEventHandlers[event];
|
||||||
|
}
|
||||||
|
|
||||||
|
public onControl<K extends keyof ControlEvents>(event: K, handler: ControlEvents[K]): void {
|
||||||
|
this.eventHandlers[event] = handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
public offControl<K extends keyof ControlEvents>(event: K): void {
|
||||||
|
delete this.eventHandlers[event];
|
||||||
|
}
|
||||||
|
|
||||||
|
public get connectionState(): ConnectionState {
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get currentUserId(): UserId | null {
|
||||||
|
return this.userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get currentSessionKey(): string | null {
|
||||||
|
return this.sessionKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Private methods
|
||||||
|
private connectToWebSocket(): void {
|
||||||
|
this.setState(ConnectionState.CONNECTING);
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.socket = new WebSocket(this.url);
|
||||||
|
|
||||||
|
this.socket.onopen = this.onSocketOpen.bind(this);
|
||||||
|
this.socket.onmessage = this.onSocketMessage.bind(this);
|
||||||
|
this.socket.onerror = this.onSocketError.bind(this);
|
||||||
|
this.socket.onclose = this.onSocketClose.bind(this);
|
||||||
|
} catch (error) {
|
||||||
|
this.emitError(new Error('Failed to create WebSocket connection'));
|
||||||
|
this.setState(ConnectionState.ERROR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private onSocketOpen(): void {
|
||||||
|
this.connectionLock = false;
|
||||||
|
|
||||||
|
logger.log('Socket opened');
|
||||||
|
this.setState(ConnectionState.AUTHENTICATING);
|
||||||
|
|
||||||
|
if (this.token) {
|
||||||
|
this.sendMessage({
|
||||||
|
type: ClientMessageType.AUTHENTICATE,
|
||||||
|
data: { token: this.token }
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.emitError(new Error('No authentication token provided'));
|
||||||
|
this.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private onSocketMessage(event: MessageEvent): void {
|
||||||
|
try {
|
||||||
|
const message = JSON.parse(event.data) as ServerMessage;
|
||||||
|
this.handleServerMessage(message);
|
||||||
|
} catch (error) {
|
||||||
|
this.emitError(new Error('Failed to parse WebSocket message', { cause: error }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private onSocketError(event: Event): void {
|
||||||
|
this.connectionLock = false;
|
||||||
|
logger.log('Socket error: %s', event);
|
||||||
|
|
||||||
|
this.emitError(new Error('WebSocket error occurred'));
|
||||||
|
}
|
||||||
|
|
||||||
|
private onSocketClose(event: CloseEvent): void {
|
||||||
|
logger.log('Socket closed: %s', event);
|
||||||
|
|
||||||
|
this.connectionLock = false;
|
||||||
|
|
||||||
|
if (
|
||||||
|
this.options.reconnect &&
|
||||||
|
!this.closeInitiatedByClient &&
|
||||||
|
this.reconnectAttempts < this.options.maxReconnectAttempts
|
||||||
|
) {
|
||||||
|
logger.log('Reconnecting in %d seconds (%d/%d)', this.options.reconnectDelay / 1000, this.reconnectAttempts + 1, this.options.maxReconnectAttempts);
|
||||||
|
this.reconnectAttempts++;
|
||||||
|
|
||||||
|
this.reconnectTimeout = setTimeout(() => {
|
||||||
|
if (this.token) {
|
||||||
|
this.connectToWebSocket();
|
||||||
|
}
|
||||||
|
}, this.options.reconnectDelay);
|
||||||
|
} else {
|
||||||
|
this.setState(ConnectionState.DISCONNECTED);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleServerMessage(message: ServerMessage): void {
|
||||||
|
logger.log('Received message: ', message);
|
||||||
|
|
||||||
|
switch (message.type) {
|
||||||
|
case ServerMessageType.AUTHENTICATE_ACCEPTED:
|
||||||
|
this.userId = message.data.userId;
|
||||||
|
this.sessionKey = message.data.sessionKey;
|
||||||
|
this.setState(ConnectionState.CONNECTED);
|
||||||
|
this.emitControl('authenticated', message.data.userId, message.data.sessionKey);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ServerMessageType.AUTHENTICATE_DENIED:
|
||||||
|
this.emitError(new Error('Authentication denied'));
|
||||||
|
this.disconnect();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ServerMessageType.ERROR:
|
||||||
|
this.emitError(new Error(`Server error: ${message.data.code}`), message.data.code);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ServerMessageType.EVENT:
|
||||||
|
this.handleEventMessage(message.data.event);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.warn('Unhandled server message type:', message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleEventMessage(event: EventData): void {
|
||||||
|
this.emitEvent(event.type, event.data as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sendMessage(message: ClientMessage): void {
|
||||||
|
logger.log('Sending message: %o', message);
|
||||||
|
|
||||||
|
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
|
||||||
|
this.socket.send(JSON.stringify(message));
|
||||||
|
} else {
|
||||||
|
this.emitError(new Error('Cannot send message: socket not connected'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setState(state: ConnectionState): void {
|
||||||
|
if (this.state !== state) {
|
||||||
|
logger.log('State changed to %s', state);
|
||||||
|
|
||||||
|
this.state = state;
|
||||||
|
this.emitControl('stateChange', state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitError(error: Error, code?: ErrorCode): void {
|
||||||
|
logger.error('Error: %s', error, error.cause);
|
||||||
|
|
||||||
|
this.setState(ConnectionState.ERROR);
|
||||||
|
this.emitControl('error', error, code);
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitControl<K extends keyof ControlEvents>(event: K, ...args: Parameters<ControlEvents[K]>): void {
|
||||||
|
const handler = this.eventHandlers[event];
|
||||||
|
if (handler) {
|
||||||
|
(handler as Function)(...args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitEvent<K extends keyof GatewayEvents>(event: K, ...args: Parameters<GatewayEvents[K]>): void {
|
||||||
|
const handler = this.serverEventHandlers[event];
|
||||||
|
if (handler) {
|
||||||
|
(handler as Function)(...args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private cleanupSocket(): void {
|
||||||
|
logger.log('Cleaning up socket');
|
||||||
|
|
||||||
|
if (this.reconnectTimeout) {
|
||||||
|
clearTimeout(this.reconnectTimeout);
|
||||||
|
this.reconnectTimeout = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.socket) {
|
||||||
|
// Remove all event listeners
|
||||||
|
this.socket.onopen = null;
|
||||||
|
this.socket.onmessage = null;
|
||||||
|
this.socket.onerror = null;
|
||||||
|
this.socket.onclose = null;
|
||||||
|
|
||||||
|
// Close the connection if it's still open
|
||||||
|
if (this.socket.readyState === WebSocket.OPEN ||
|
||||||
|
this.socket.readyState === WebSocket.CONNECTING) {
|
||||||
|
this.socket.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.socket = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sessionKey = null;
|
||||||
|
this.userId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const logger = createPrefixedLogger('%cGateway WS%c:', ['color: red; font-weight: bold;', '']);
|
||||||
249
app/lib/websocket/gateway/types.ts
Normal file
249
app/lib/websocket/gateway/types.ts
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
import type { ChannelId, Message, MessageId, PartialUser, Server, ServerId, UserId } from "~/lib/api/types";
|
||||||
|
|
||||||
|
type Channel = any; // TODO: Define Channel type
|
||||||
|
|
||||||
|
export enum ServerMessageType {
|
||||||
|
AUTHENTICATE_ACCEPTED = 'AUTHENTICATE_ACCEPTED',
|
||||||
|
AUTHENTICATE_DENIED = 'AUTHENTICATE_DENIED',
|
||||||
|
EVENT = 'EVENT',
|
||||||
|
ERROR = 'ERROR'
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ClientMessageType {
|
||||||
|
AUTHENTICATE = 'AUTHENTICATE',
|
||||||
|
VOICE_STATE_UPDATE = 'VOICE_STATE_UPDATE',
|
||||||
|
REQUEST_VOICE_STATES = 'REQUEST_VOICE_STATES',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error codes from the server
|
||||||
|
export enum ErrorCode {
|
||||||
|
AUTHENTICATION_FAILED = 'AUTHENTICATION_FAILED',
|
||||||
|
TOKEN_GENERATION_FAILED = 'TOKEN_GENERATION_FAILED'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event types from the server
|
||||||
|
export enum EventType {
|
||||||
|
ADD_SERVER = 'ADD_SERVER',
|
||||||
|
REMOVE_SERVER = 'REMOVE_SERVER',
|
||||||
|
|
||||||
|
ADD_DM_CHANNEL = 'ADD_DM_CHANNEL',
|
||||||
|
REMOVE_DM_CHANNEL = 'REMOVE_DM_CHANNEL',
|
||||||
|
|
||||||
|
ADD_SERVER_CHANNEL = 'ADD_SERVER_CHANNEL',
|
||||||
|
REMOVE_SERVER_CHANNEL = 'REMOVE_SERVER_CHANNEL',
|
||||||
|
|
||||||
|
ADD_USER = 'ADD_USER',
|
||||||
|
REMOVE_USER = 'REMOVE_USER',
|
||||||
|
|
||||||
|
ADD_SERVER_MEMBER = 'ADD_SERVER_MEMBER',
|
||||||
|
REMOVE_SERVER_MEMBER = 'REMOVE_SERVER_MEMBER',
|
||||||
|
|
||||||
|
ADD_MESSAGE = 'ADD_MESSAGE',
|
||||||
|
REMOVE_MESSAGE = 'REMOVE_MESSAGE',
|
||||||
|
|
||||||
|
VOICE_CHANNEL_CONNECTED = 'VOICE_CHANNEL_CONNECTED',
|
||||||
|
VOICE_CHANNEL_DISCONNECTED = 'VOICE_CHANNEL_DISCONNECTED',
|
||||||
|
|
||||||
|
VOICE_SERVER_UPDATE = 'VOICE_SERVER_UPDATE'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client message types
|
||||||
|
export interface AuthenticateMessage {
|
||||||
|
type: ClientMessageType.AUTHENTICATE;
|
||||||
|
data: {
|
||||||
|
token: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VoiceStateUpdateMessage {
|
||||||
|
type: ClientMessageType.VOICE_STATE_UPDATE;
|
||||||
|
data: {
|
||||||
|
serverId: ServerId;
|
||||||
|
channelId: ChannelId;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RequestVoiceStatesMessage {
|
||||||
|
type: ClientMessageType.REQUEST_VOICE_STATES;
|
||||||
|
data: {
|
||||||
|
serverId: ServerId;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ClientMessage = AuthenticateMessage | VoiceStateUpdateMessage | RequestVoiceStatesMessage;
|
||||||
|
|
||||||
|
// Server message types
|
||||||
|
export interface AuthenticateAcceptedMessage {
|
||||||
|
type: ServerMessageType.AUTHENTICATE_ACCEPTED;
|
||||||
|
data: {
|
||||||
|
userId: UserId;
|
||||||
|
sessionKey: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthenticateDeniedMessage {
|
||||||
|
type: ServerMessageType.AUTHENTICATE_DENIED;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ErrorMessage {
|
||||||
|
type: ServerMessageType.ERROR;
|
||||||
|
data: {
|
||||||
|
code: ErrorCode;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event message types
|
||||||
|
export interface AddServerEvent {
|
||||||
|
type: EventType.ADD_SERVER;
|
||||||
|
data: {
|
||||||
|
server: Server;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemoveServerEvent {
|
||||||
|
type: EventType.REMOVE_SERVER;
|
||||||
|
data: {
|
||||||
|
serverId: ServerId;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AddDmChannelEvent {
|
||||||
|
type: EventType.ADD_DM_CHANNEL;
|
||||||
|
data: {
|
||||||
|
channel: Channel;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemoveDmChannelEvent {
|
||||||
|
type: EventType.REMOVE_DM_CHANNEL;
|
||||||
|
data: {
|
||||||
|
channelId: ChannelId;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AddServerChannelEvent {
|
||||||
|
type: EventType.ADD_SERVER_CHANNEL;
|
||||||
|
data: {
|
||||||
|
channel: Channel;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemoveServerChannelEvent {
|
||||||
|
type: EventType.REMOVE_SERVER_CHANNEL;
|
||||||
|
data: {
|
||||||
|
serverId: ServerId;
|
||||||
|
channelId: ChannelId;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AddUserEvent {
|
||||||
|
type: EventType.ADD_USER;
|
||||||
|
data: {
|
||||||
|
user: PartialUser;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemoveUserEvent {
|
||||||
|
type: EventType.REMOVE_USER;
|
||||||
|
data: {
|
||||||
|
userId: UserId;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AddServerMemberEvent {
|
||||||
|
type: EventType.ADD_SERVER_MEMBER;
|
||||||
|
data: {
|
||||||
|
serverId: ServerId;
|
||||||
|
user: PartialUser;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemoveServerMemberEvent {
|
||||||
|
type: EventType.REMOVE_SERVER_MEMBER;
|
||||||
|
data: {
|
||||||
|
serverId: ServerId;
|
||||||
|
userId: UserId;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AddMessageEvent {
|
||||||
|
type: EventType.ADD_MESSAGE;
|
||||||
|
data: {
|
||||||
|
channelId: ChannelId;
|
||||||
|
message: Message;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemoveMessageEvent {
|
||||||
|
type: EventType.REMOVE_MESSAGE;
|
||||||
|
data: {
|
||||||
|
channelId: ChannelId;
|
||||||
|
messageId: MessageId;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VoiceChannelConnectedEvent {
|
||||||
|
type: EventType.VOICE_CHANNEL_CONNECTED;
|
||||||
|
data: {
|
||||||
|
serverId: ServerId;
|
||||||
|
channelId: ChannelId;
|
||||||
|
userId: UserId;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VoiceChannelDisconnectedEvent {
|
||||||
|
type: EventType.VOICE_CHANNEL_DISCONNECTED;
|
||||||
|
data: {
|
||||||
|
serverId: ServerId;
|
||||||
|
channelId: ChannelId;
|
||||||
|
userId: UserId;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VoiceServerUpdateEvent {
|
||||||
|
type: EventType.VOICE_SERVER_UPDATE;
|
||||||
|
data: {
|
||||||
|
serverId: ServerId;
|
||||||
|
channelId: ChannelId;
|
||||||
|
token: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EventData =
|
||||||
|
| AddServerEvent
|
||||||
|
| RemoveServerEvent
|
||||||
|
| AddDmChannelEvent
|
||||||
|
| RemoveDmChannelEvent
|
||||||
|
| AddServerChannelEvent
|
||||||
|
| RemoveServerChannelEvent
|
||||||
|
| AddUserEvent
|
||||||
|
| RemoveUserEvent
|
||||||
|
| AddServerMemberEvent
|
||||||
|
| RemoveServerMemberEvent
|
||||||
|
| AddMessageEvent
|
||||||
|
| RemoveMessageEvent
|
||||||
|
| VoiceChannelConnectedEvent
|
||||||
|
| VoiceChannelDisconnectedEvent
|
||||||
|
| VoiceServerUpdateEvent;
|
||||||
|
|
||||||
|
export interface EventMessage {
|
||||||
|
type: ServerMessageType.EVENT;
|
||||||
|
data: {
|
||||||
|
event: EventData;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ServerMessage =
|
||||||
|
| AuthenticateAcceptedMessage
|
||||||
|
| AuthenticateDeniedMessage
|
||||||
|
| EventMessage
|
||||||
|
| ErrorMessage;
|
||||||
|
|
||||||
|
// Connection states
|
||||||
|
export enum ConnectionState {
|
||||||
|
DISCONNECTED = 'DISCONNECTED',
|
||||||
|
CONNECTING = 'CONNECTING',
|
||||||
|
AUTHENTICATING = 'AUTHENTICATING',
|
||||||
|
CONNECTED = 'CONNECTED',
|
||||||
|
ERROR = 'ERROR'
|
||||||
|
}
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
export type WebSocketStatus =
|
|
||||||
| "IDLE" // Initial state, no connection attempt made
|
|
||||||
| "CONNECTING" // WebSocket connection is being established
|
|
||||||
| "AUTHENTICATING"// WebSocket connected, awaiting authentication response
|
|
||||||
| "CONNECTED" // Authenticated and ready for general messages
|
|
||||||
| "DISCONNECTED" // Intentionally disconnected or connection lost (no retry)
|
|
||||||
| "ERROR"; // An error occurred (network, auth, server-reported, no retry)
|
|
||||||
|
|
||||||
// Server -> Client Payloads
|
|
||||||
export interface SdpAnswerVoicePayload {
|
|
||||||
sdp: RTCSessionDescriptionInit; // From browser's RTCSessionDescriptionInit
|
|
||||||
}
|
|
||||||
|
|
||||||
// Server -> Client Messages
|
|
||||||
export type VoiceServerMessage =
|
|
||||||
| { type: "AUTHENTICATE_ACCEPTED"; data?: Record<string, never> }
|
|
||||||
| { type: "AUTHENTICATE_DENIED"; data?: { reason?: string } }
|
|
||||||
| { type: "SDP_ANSWER"; data: SdpAnswerVoicePayload }
|
|
||||||
| { type: "ERROR"; data: { code: string | number; message?: string } };
|
|
||||||
|
|
||||||
// Client -> Server Payloads
|
|
||||||
export interface AuthenticateVoicePayload {
|
|
||||||
token: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SdpOfferVoicePayload {
|
|
||||||
sdp: RTCSessionDescriptionInit;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Client -> Server Messages
|
|
||||||
export type VoiceClientMessage =
|
|
||||||
| { type: "AUTHENTICATE"; data: AuthenticateVoicePayload }
|
|
||||||
| { type: "SDP_OFFER"; data: SdpOfferVoicePayload };
|
|
||||||
271
app/lib/websocket/voice/client.ts
Normal file
271
app/lib/websocket/voice/client.ts
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
import { createPrefixedLogger } from "~/lib/utils";
|
||||||
|
import { ClientMessageType, ConnectionState, ServerMessageType, type ClientMessage, type ServerMessage } from "./types";
|
||||||
|
|
||||||
|
export class WebRTCClient {
|
||||||
|
private socket: WebSocket | null = null;
|
||||||
|
private peerConnection: RTCPeerConnection | null = null;
|
||||||
|
|
||||||
|
private onStateChange: (state: ConnectionState) => void;
|
||||||
|
private onError: (error: Error) => void;
|
||||||
|
private onRemoteStream: (stream: MediaStream) => void;
|
||||||
|
|
||||||
|
private state: ConnectionState = ConnectionState.DISCONNECTED;
|
||||||
|
private url: string;
|
||||||
|
|
||||||
|
private connectionLock = false;
|
||||||
|
private disconnectPromise: Promise<void> | null = null;
|
||||||
|
private disconnectResolve: (() => void) | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
url: string,
|
||||||
|
onStateChange: (state: ConnectionState) => void,
|
||||||
|
onError: (error: Error) => void,
|
||||||
|
onRemoteStream: (stream: MediaStream) => void
|
||||||
|
) {
|
||||||
|
this.url = url;
|
||||||
|
this.onStateChange = onStateChange;
|
||||||
|
this.onError = onError;
|
||||||
|
this.onRemoteStream = onRemoteStream;
|
||||||
|
}
|
||||||
|
|
||||||
|
public connect = async (token: string) => {
|
||||||
|
if (this.connectionLock) {
|
||||||
|
warn('WebRTC: Connection already in progress');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.connectionLock = true;
|
||||||
|
|
||||||
|
if (this.state !== ConnectionState.DISCONNECTED && this.state !== ConnectionState.ERROR) {
|
||||||
|
this.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.disconnectPromise) {
|
||||||
|
warn('WebRTC: Waiting for previous disconnect to complete');
|
||||||
|
try {
|
||||||
|
await this.disconnectPromise;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('WebRTC: Previous disconnect failed:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log('Connecting to %s', this.url);
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.setState(ConnectionState.CONNECTING);
|
||||||
|
|
||||||
|
this.socket = new WebSocket(this.url);
|
||||||
|
|
||||||
|
this.socket.onopen = () => {
|
||||||
|
log('Socket opened');
|
||||||
|
|
||||||
|
this.connectionLock = false;
|
||||||
|
|
||||||
|
this.setState(ConnectionState.AUTHENTICATING);
|
||||||
|
this.sendMessage({
|
||||||
|
type: ClientMessageType.AUTHENTICATE,
|
||||||
|
data: { token }
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
this.socket.onmessage = this.handleServerMessage;
|
||||||
|
|
||||||
|
this.socket.onerror = (event) => {
|
||||||
|
this.handleError(new Error('WebSocket error occurred'));
|
||||||
|
};
|
||||||
|
|
||||||
|
this.socket.onclose = (e) => {
|
||||||
|
log('Socket closed', e);
|
||||||
|
this.cleanupResources();
|
||||||
|
if (this.state !== ConnectionState.ERROR) {
|
||||||
|
this.setState(ConnectionState.DISCONNECTED);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.disconnectResolve) {
|
||||||
|
this.disconnectResolve();
|
||||||
|
this.disconnectResolve = null;
|
||||||
|
this.disconnectPromise = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.handleError(error instanceof Error ? error : new Error('Unknown error'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
public disconnect = (): void => {
|
||||||
|
if (this.state === ConnectionState.DISCONNECTED) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setState(ConnectionState.DISCONNECTING);
|
||||||
|
this.connectionLock = false;
|
||||||
|
|
||||||
|
if (this.socket && this.socket.readyState !== WebSocket.CLOSED) {
|
||||||
|
// If we're already waiting for a disconnect, cancel it
|
||||||
|
if (this.disconnectPromise) {
|
||||||
|
this.disconnectResolve = null;
|
||||||
|
this.disconnectPromise = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.disconnectPromise = new Promise((resolve) => {
|
||||||
|
this.disconnectResolve = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSocketClose = () => {
|
||||||
|
this.socket?.removeEventListener('close', onSocketClose);
|
||||||
|
this.disconnectResolve?.();
|
||||||
|
this.disconnectResolve = null;
|
||||||
|
this.disconnectPromise = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
this.socket.addEventListener('close', onSocketClose);
|
||||||
|
|
||||||
|
if (this.socket.readyState !== WebSocket.CLOSING) {
|
||||||
|
this.socket.close(1000, 'WebRTC: Cleaning up resources');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.cleanupResources();
|
||||||
|
this.setState(ConnectionState.DISCONNECTED);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
public createOffer = async (localStream?: MediaStream): Promise<void> => {
|
||||||
|
if (this.state !== ConnectionState.CONNECTED) {
|
||||||
|
this.handleError(new Error('Cannot create offer: not connected'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create RTCPeerConnection with standard configuration
|
||||||
|
const configuration: RTCConfiguration = {
|
||||||
|
iceServers: []
|
||||||
|
};
|
||||||
|
|
||||||
|
this.peerConnection = new RTCPeerConnection(configuration);
|
||||||
|
|
||||||
|
// Add local stream tracks if provided
|
||||||
|
if (localStream) {
|
||||||
|
localStream.getTracks().forEach(track => {
|
||||||
|
this.peerConnection!.addTrack(track, localStream);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle ICE candidates
|
||||||
|
this.peerConnection.onicecandidate = (event) => {
|
||||||
|
if (event.candidate === null) {
|
||||||
|
// ICE gathering completed
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle remote stream
|
||||||
|
this.peerConnection.ontrack = (event) => {
|
||||||
|
const [remoteStream] = event.streams;
|
||||||
|
if (remoteStream) {
|
||||||
|
this.onRemoteStream(remoteStream);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create offer and set local description
|
||||||
|
const offer = await this.peerConnection.createOffer();
|
||||||
|
await this.peerConnection.setLocalDescription(offer);
|
||||||
|
|
||||||
|
// Send offer to server
|
||||||
|
if (this.peerConnection.localDescription) {
|
||||||
|
this.sendMessage({
|
||||||
|
type: ClientMessageType.SDP_OFFER,
|
||||||
|
data: {
|
||||||
|
sdp: this.peerConnection.localDescription
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.handleError(error instanceof Error ? error : new Error('Error creating WebRTC offer'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private handleServerMessage = async (event: MessageEvent): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const message: ServerMessage = JSON.parse(event.data);
|
||||||
|
|
||||||
|
log('Received message: %o', message);
|
||||||
|
|
||||||
|
switch (message.type) {
|
||||||
|
case ServerMessageType.AUTHENTICATE_ACCEPTED:
|
||||||
|
this.setState(ConnectionState.CONNECTED);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ServerMessageType.AUTHENTICATE_DENIED:
|
||||||
|
this.handleError(new Error('Authentication failed'));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ServerMessageType.SDP_ANSWER:
|
||||||
|
await this.handleSdpAnswer(message.data.sdp);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
warn('Unhandled message type:', message);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.handleError(error instanceof Error ? error : new Error('Failed to process message'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private handleSdpAnswer = async (sdp: RTCSessionDescription): Promise<void> => {
|
||||||
|
log('Received SDP answer: %o', sdp);
|
||||||
|
|
||||||
|
if (!this.peerConnection) {
|
||||||
|
this.handleError(new Error('No peer connection established'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.peerConnection.setRemoteDescription(sdp);
|
||||||
|
} catch (error) {
|
||||||
|
this.handleError(error instanceof Error ? error : new Error('Error setting remote description'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private sendMessage = (message: ClientMessage): void => {
|
||||||
|
log('Sending message: %o', message);
|
||||||
|
|
||||||
|
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
|
||||||
|
this.socket.send(JSON.stringify(message));
|
||||||
|
} else {
|
||||||
|
this.handleError(new Error('Cannot send message: socket not connected'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private setState = (state: ConnectionState): void => {
|
||||||
|
log('State changed to %s', state);
|
||||||
|
|
||||||
|
this.state = state;
|
||||||
|
this.onStateChange(state);
|
||||||
|
};
|
||||||
|
|
||||||
|
private handleError = (error: Error): void => {
|
||||||
|
log('Error: %s', error.message);
|
||||||
|
|
||||||
|
this.setState(ConnectionState.ERROR);
|
||||||
|
this.onError(error);
|
||||||
|
};
|
||||||
|
|
||||||
|
private cleanupResources = (): void => {
|
||||||
|
log('Cleaning up resources');
|
||||||
|
|
||||||
|
if (this.peerConnection) {
|
||||||
|
this.peerConnection.close();
|
||||||
|
this.peerConnection = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.socket) {
|
||||||
|
this.socket.close(1000, 'WebRTC: Cleaning up resources');
|
||||||
|
this.socket = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
log,
|
||||||
|
warn,
|
||||||
|
...other
|
||||||
|
} = createPrefixedLogger('%cWebRTC WS%c:', ['color: blue; font-weight: bold;', '']);
|
||||||
36
app/lib/websocket/voice/types.ts
Normal file
36
app/lib/websocket/voice/types.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
export enum ConnectionState {
|
||||||
|
DISCONNECTED = 'DISCONNECTED',
|
||||||
|
DISCONNECTING = 'DISCONNECTING',
|
||||||
|
CONNECTING = 'CONNECTING',
|
||||||
|
AUTHENTICATING = 'AUTHENTICATING',
|
||||||
|
CONNECTED = 'CONNECTED',
|
||||||
|
ERROR = 'ERROR',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ServerMessageType {
|
||||||
|
AUTHENTICATE_ACCEPTED = 'AUTHENTICATE_ACCEPTED',
|
||||||
|
AUTHENTICATE_DENIED = 'AUTHENTICATE_DENIED',
|
||||||
|
SDP_ANSWER = 'SDP_ANSWER',
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ServerMessage =
|
||||||
|
| { type: ServerMessageType.AUTHENTICATE_ACCEPTED }
|
||||||
|
| { type: ServerMessageType.AUTHENTICATE_DENIED }
|
||||||
|
| {
|
||||||
|
type: ServerMessageType.SDP_ANSWER; data: {
|
||||||
|
sdp: RTCSessionDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ClientMessageType {
|
||||||
|
AUTHENTICATE = 'AUTHENTICATE',
|
||||||
|
SDP_OFFER = 'SDP_OFFER',
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ClientMessage =
|
||||||
|
| { type: ClientMessageType.AUTHENTICATE; data: { token: string } }
|
||||||
|
| {
|
||||||
|
type: ClientMessageType.SDP_OFFER; data: {
|
||||||
|
sdp: RTCSessionDescription
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -7,8 +7,11 @@ export default [
|
|||||||
route("/register", "routes/auth/register.tsx"),
|
route("/register", "routes/auth/register.tsx"),
|
||||||
]),
|
]),
|
||||||
...prefix("/app", [
|
...prefix("/app", [
|
||||||
|
layout("routes/app/providers.tsx", [
|
||||||
|
route("/settings", "routes/app/settings.tsx"),
|
||||||
layout("routes/app/layout.tsx", [
|
layout("routes/app/layout.tsx", [
|
||||||
index("routes/app/index.tsx"),
|
index("routes/app/index.tsx"),
|
||||||
|
route("/invite/:inviteCode", "routes/app/invite.tsx"),
|
||||||
...prefix("/@me", [
|
...prefix("/@me", [
|
||||||
layout("routes/app/me/layout.tsx", [
|
layout("routes/app/me/layout.tsx", [
|
||||||
index("routes/app/me/index.tsx"),
|
index("routes/app/me/index.tsx"),
|
||||||
@@ -18,9 +21,9 @@ export default [
|
|||||||
...prefix("/server/:serverId", [
|
...prefix("/server/:serverId", [
|
||||||
layout("routes/app/server/layout.tsx", [
|
layout("routes/app/server/layout.tsx", [
|
||||||
index("routes/app/server/index.tsx"),
|
index("routes/app/server/index.tsx"),
|
||||||
route("/channels/:channelId", "routes/app/server/channel.tsx"),
|
route("/:channelId", "routes/app/server/channel.tsx"),
|
||||||
])
|
|
||||||
])
|
])
|
||||||
])
|
])
|
||||||
|
])]),
|
||||||
]),
|
]),
|
||||||
] satisfies RouteConfig;
|
] satisfies RouteConfig;
|
||||||
|
|||||||
@@ -1,58 +1,9 @@
|
|||||||
import ChannelListItem from "~/components/channel-list-item";
|
import { redirect } from "react-router";
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "~/components/ui/dropdown-menu";
|
|
||||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
|
||||||
|
|
||||||
export const handle = {
|
export function clientLoader() {
|
||||||
listComponent: (
|
return redirect("/app/@me")
|
||||||
<>
|
|
||||||
<div className="h-full flex flex-col">
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger className="w-full min-h-12">
|
|
||||||
<div className="border-b-2 h-full flex items-center justify-center">
|
|
||||||
Server Name
|
|
||||||
</div>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent>
|
|
||||||
<DropdownMenuLabel>My Account</DropdownMenuLabel>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem>Profile</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem>Billing</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem>Team</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem>Subscription</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
<ScrollArea className="overflow-auto" scrollbarSize="narrow">
|
|
||||||
<div className="p-2 flex flex-col gap-1 h-full">
|
|
||||||
{Array.from({ length: 10 }).map((_, i) => (
|
|
||||||
<>
|
|
||||||
<ChannelListItem channel={{ id: i.toString(), name: `Channel ${i + 1}`, type: "category" }} />
|
|
||||||
<ChannelListItem channel={{ id: i.toString(), name: `Channel ${i + 1}`, type: "text" }} />
|
|
||||||
<ChannelListItem channel={{ id: i.toString(), name: `Channel ${i + 1}`, type: "voice" }} />
|
|
||||||
</>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Index() {
|
export default function Index() {
|
||||||
return (
|
return null;
|
||||||
<>
|
|
||||||
<div className="h-full">
|
|
||||||
<div className="size-full relative">
|
|
||||||
<div className="absolute bottom-0 w-full max-h-1/2">
|
|
||||||
<div className=" p-2">
|
|
||||||
<div className="max-h-96 w-full overflow-y-auto min-h-6 outline-1 p-4 rounded-xl no-scrollbar">
|
|
||||||
<div contentEditable="plaintext-only" className="break-words outline-0">
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
20
app/routes/app/invite.tsx
Normal file
20
app/routes/app/invite.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { redirect } from "react-router";
|
||||||
|
import type { Route } from "./+types/invite";
|
||||||
|
|
||||||
|
export async function clientLoader(
|
||||||
|
{ params }: Route.ClientLoaderArgs
|
||||||
|
) {
|
||||||
|
const inviteCode = params.inviteCode
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await import("~/lib/api/client/server").then(m => m.default.getInvite(inviteCode))
|
||||||
|
|
||||||
|
return redirect(`/app/server/${response.id}`)
|
||||||
|
} catch (error) {
|
||||||
|
return redirect("/app/@me")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Index() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -1,43 +1,26 @@
|
|||||||
import type React from "react";
|
import { Outlet, redirect } from "react-router";
|
||||||
import { Outlet, redirect, useMatches } from "react-router";
|
|
||||||
import AppLayout from "~/components/app-layout";
|
import AppLayout from "~/components/app-layout";
|
||||||
import { GatewayWebSocketConnectionManager } from "~/components/manager/gateway-websocket-connection-manager";
|
import { useServerListStore } from "~/stores/server-list-store";
|
||||||
import { WebRTCConnectionManager } from "~/components/manager/webrtc-connection-manager";
|
|
||||||
import { useServerListStore } from "~/store/server-list";
|
|
||||||
import { useUserStore } from "~/store/user";
|
|
||||||
import type { Route } from "../app/+types/layout";
|
|
||||||
|
|
||||||
export async function clientLoader() {
|
export async function clientLoader() {
|
||||||
let { user, setUser } = useUserStore.getState()
|
const { servers, addServers } = useServerListStore.getState()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!user) {
|
if (!servers || Object.values(servers).length === 0) {
|
||||||
const user = await import("~/lib/api/client/user").then(m => m.default.me())
|
const newServers = await import("~/lib/api/client/server").then(m => m.default.list())
|
||||||
setUser(user)
|
addServers(newServers)
|
||||||
}
|
}
|
||||||
|
|
||||||
const servers = await import("~/lib/api/client/server").then(m => m.default.list())
|
|
||||||
useServerListStore.getState().setServers(servers)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return redirect("/login")
|
return redirect("/login")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Layout({
|
export default function Layout() {
|
||||||
loaderData
|
|
||||||
}: Route.ComponentProps) {
|
|
||||||
const matches = useMatches();
|
|
||||||
|
|
||||||
let list = matches.map(match => (match.handle as { listComponent?: React.ReactNode })?.listComponent).reverse().find(component => !!component)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<AppLayout >
|
||||||
<GatewayWebSocketConnectionManager />
|
|
||||||
<WebRTCConnectionManager />
|
|
||||||
{/* <GlobalWebRTCAudioPlayer /> */}
|
|
||||||
<AppLayout list={list}>
|
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,69 +1,37 @@
|
|||||||
import type { Route } from ".react-router/types/app/routes/app/me/+types/channel";
|
import type { Route } from ".react-router/types/app/routes/app/me/+types/channel";
|
||||||
import { useRef } from "react";
|
import { Check } from "lucide-react";
|
||||||
import TextBox from "~/components/text-box";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import { Button } from "~/components/ui/button";
|
import ChannelArea from "~/components/channel-area";
|
||||||
import type { Uuid } from "~/lib/api/types";
|
import { Badge } from "~/components/ui/badge";
|
||||||
|
import { usePrivateChannelsStore } from "~/stores/private-channels-store";
|
||||||
|
import { useUsersStore } from "~/stores/users-store";
|
||||||
|
|
||||||
export default function Channel({
|
export default function Channel({
|
||||||
params
|
params
|
||||||
}: Route.ComponentProps) {
|
}: Route.ComponentProps) {
|
||||||
let channelId = params.channelId
|
const channelId = params.channelId
|
||||||
|
const currentUserId = useUsersStore(state => state.currentUserId)
|
||||||
|
|
||||||
const audioRef = useRef<HTMLAudioElement>(null)
|
const nativeChannel = usePrivateChannelsStore(useShallow(state => state.channels[channelId]))
|
||||||
let peerConnection: RTCPeerConnection | null = null
|
const recipients = nativeChannel.recipients.filter(recipient => recipient.id !== currentUserId)
|
||||||
|
|
||||||
async function testSdp(channelId: Uuid) {
|
const renderSystemBadge = recipients.some(recipient => recipient.system) && recipients.length === 1
|
||||||
const stream = await navigator.mediaDevices.getUserMedia(
|
|
||||||
{
|
|
||||||
audio: true,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const config = {
|
const channel = {
|
||||||
iceServers: [],
|
...nativeChannel,
|
||||||
};
|
name: <>
|
||||||
peerConnection = new RTCPeerConnection(config);
|
<div className="flex items-center gap-2">
|
||||||
stream.getTracks().forEach((track) => peerConnection!.addTrack(track, stream));
|
<div>
|
||||||
|
{recipients.map(recipient => recipient.displayName || recipient.username).join(", ")}
|
||||||
peerConnection.addEventListener("track", (event) => {
|
</div>
|
||||||
console.log(event);
|
{renderSystemBadge && <Badge variant="default"> <Check />System</Badge>}
|
||||||
audioRef.current!.srcObject = event.streams[0];
|
</div>
|
||||||
});
|
</>
|
||||||
|
|
||||||
const offer = await peerConnection.createOffer();
|
|
||||||
await peerConnection.setLocalDescription(offer);
|
|
||||||
|
|
||||||
console.log(offer);
|
|
||||||
|
|
||||||
const answer = await import("~/lib/api/client/test").then(m => m.default.test(channelId, offer))
|
|
||||||
|
|
||||||
await peerConnection.setRemoteDescription(answer);
|
|
||||||
|
|
||||||
console.log(answer);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<audio autoPlay ref={audioRef} />
|
<ChannelArea channel={channel} />
|
||||||
<div className="h-full">
|
|
||||||
<div className="size-full relative">
|
|
||||||
<Button variant="secondary" onClick={() => testSdp('0196cbc6-5321-7531-974c-c87bd3066e14')}>
|
|
||||||
Test SDP
|
|
||||||
</Button>
|
|
||||||
<div className="absolute bottom-0 w-full max-h-1/2">
|
|
||||||
<div className="p-2">
|
|
||||||
|
|
||||||
<TextBox value={""}
|
|
||||||
onChange={(m) => { }}
|
|
||||||
placeholder="Type your message here..."
|
|
||||||
// Example of custom styling:
|
|
||||||
// wrapperClassName="bg-gray-700 border-gray-600 rounded-lg"
|
|
||||||
// inputClassName="text-lg"
|
|
||||||
aria-label="Message input" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
import TextBox from "~/components/text-box";
|
|
||||||
|
|
||||||
export default function Index() {
|
export default function Index() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="h-full">
|
{/* <div className="h-full">
|
||||||
<div className="size-full relative">
|
<div className="size-full relative">
|
||||||
<div className="absolute bottom-0 w-full max-h-1/2">
|
<div className="absolute bottom-0 w-full max-h-1/2">
|
||||||
<div className="p-2">
|
<div className="p-2">
|
||||||
@@ -17,7 +16,7 @@ export default function Index() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div> */}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,23 @@
|
|||||||
|
import React from "react";
|
||||||
import { Outlet } from "react-router";
|
import { Outlet } from "react-router";
|
||||||
import PrivateChannelListItem from "~/components/private-channel-list-item";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
|
import PrivateChannelListItem from "~/components/custom-ui/private-channel-list-item";
|
||||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||||
import { usePrivateChannelsStore } from "~/store/private-channels";
|
import { usePrivateChannelsStore } from "~/stores/private-channels-store";
|
||||||
|
|
||||||
export async function clientLoader() {
|
export async function clientLoader() {
|
||||||
const {channels, setChannels} = usePrivateChannelsStore.getState()
|
const { channels, addChannels: setChannels } = usePrivateChannelsStore.getState()
|
||||||
|
|
||||||
if (!channels || channels.length === 0) {
|
const channelList = Object.values(channels)
|
||||||
|
|
||||||
|
if (!channels || channelList.length === 0) {
|
||||||
const channels = await import("~/lib/api/client/user").then(m => m.default.channels())
|
const channels = await import("~/lib/api/client/user").then(m => m.default.channels())
|
||||||
setChannels(channels)
|
setChannels(channels)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function ListComponent() {
|
function ListComponent() {
|
||||||
const channels = usePrivateChannelsStore(state => state.channels)
|
const channels = usePrivateChannelsStore(useShallow(state => Object.values(state.channels)))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col">
|
<div className="h-full flex flex-col">
|
||||||
@@ -26,9 +30,9 @@ function ListComponent() {
|
|||||||
<ScrollArea className="overflow-auto" scrollbarSize="narrow">
|
<ScrollArea className="overflow-auto" scrollbarSize="narrow">
|
||||||
<div className="p-2 flex flex-col gap-1 h-full">
|
<div className="p-2 flex flex-col gap-1 h-full">
|
||||||
{channels.sort((a, b) => (a.lastMessageId ?? a.id) < (b.lastMessageId ?? b.id) ? 1 : -1).map((channel, _) => (
|
{channels.sort((a, b) => (a.lastMessageId ?? a.id) < (b.lastMessageId ?? b.id) ? 1 : -1).map((channel, _) => (
|
||||||
<>
|
<React.Fragment key={channel.id}>
|
||||||
<PrivateChannelListItem channel={channel} />
|
<PrivateChannelListItem channel={channel} />
|
||||||
</>
|
</React.Fragment>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|||||||
38
app/routes/app/providers.tsx
Normal file
38
app/routes/app/providers.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { Outlet, redirect } from "react-router";
|
||||||
|
import { create } from "zustand";
|
||||||
|
import { GatewayWebSocketConnectionManager } from "~/components/manager/gateway-websocket-connection-manager";
|
||||||
|
import { WebRTCConnectionManager } from "~/components/manager/webrtc-connection-manager";
|
||||||
|
import ModalProvider from "~/components/providers/modal-provider";
|
||||||
|
import { useUsersStore } from "~/stores/users-store";
|
||||||
|
|
||||||
|
export async function clientLoader() {
|
||||||
|
const { currentUserId, setCurrentUserId, addUser } = useUsersStore.getState()
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!currentUserId) {
|
||||||
|
const user = await import("~/lib/api/client/user").then(m => m.default.me())
|
||||||
|
setCurrentUserId(user.id)
|
||||||
|
addUser(user)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return redirect("/login")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const useQueryClient = create(() => new QueryClient());
|
||||||
|
|
||||||
|
export default function Layout() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<>
|
||||||
|
<ModalProvider />
|
||||||
|
<GatewayWebSocketConnectionManager />
|
||||||
|
<WebRTCConnectionManager />
|
||||||
|
<Outlet />
|
||||||
|
</>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,23 +1,16 @@
|
|||||||
import TextBox from "~/components/text-box";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
|
import ChannelArea from "~/components/channel-area";
|
||||||
|
import { useServerChannelsStore } from "~/stores/server-channels-store";
|
||||||
|
import type { Route } from "./+types/channel";
|
||||||
|
|
||||||
|
export default function Channel(
|
||||||
|
{ params: { serverId, channelId } }: Route.ComponentProps
|
||||||
|
) {
|
||||||
|
const channel = useServerChannelsStore(useShallow(state => state.channels[serverId][channelId]))
|
||||||
|
|
||||||
export default function Channel() {
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="h-full">
|
<ChannelArea channel={channel} />
|
||||||
<div className="size-full relative">
|
|
||||||
<div className="absolute bottom-0 w-full max-h-1/2">
|
|
||||||
<div className="p-2">
|
|
||||||
<TextBox value={""}
|
|
||||||
onChange={(m) => { }}
|
|
||||||
placeholder="Type your message here..."
|
|
||||||
// Example of custom styling:
|
|
||||||
// wrapperClassName="bg-gray-700 border-gray-600 rounded-lg"
|
|
||||||
// inputClassName="text-lg"
|
|
||||||
aria-label="Message input" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
import TextBox from "~/components/text-box";
|
|
||||||
|
|
||||||
export default function Index() {
|
export default function Index() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="h-full">
|
{/* <div className="h-full">
|
||||||
<div className="size-full relative">
|
<div className="size-full relative">
|
||||||
<div className="absolute bottom-0 w-full max-h-1/2">
|
<div className="absolute bottom-0 w-full max-h-1/2">
|
||||||
<div className="p-2">
|
<div className="p-2">
|
||||||
@@ -17,7 +16,7 @@ export default function Index() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div> */}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,38 +1,85 @@
|
|||||||
import { Outlet, useParams } from "react-router";
|
import React from "react";
|
||||||
import ChannelListItem from "~/components/channel-list-item";
|
import { Outlet, redirect, useNavigate, useParams, type ShouldRevalidateFunctionArgs } from "react-router";
|
||||||
|
import { useShallow } from "zustand/react/shallow";
|
||||||
|
import ServerChannelListItem from "~/components/custom-ui/channel-list-item";
|
||||||
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "~/components/ui/dropdown-menu";
|
||||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||||
import { usePrivateChannelsStore } from "~/store/private-channels";
|
import type { ServerId } from "~/lib/api/types";
|
||||||
import { useServerListStore } from "~/store/server-list";
|
import { useGatewayStore } from "~/stores/gateway-store";
|
||||||
|
import { ModalType, useModalStore } from "~/stores/modal-store";
|
||||||
|
import { useServerChannelsStore } from "~/stores/server-channels-store";
|
||||||
|
import { useServerListStore } from "~/stores/server-list-store";
|
||||||
|
import { useUsersStore } from "~/stores/users-store";
|
||||||
|
import type { Route } from "../server/+types/layout";
|
||||||
|
|
||||||
export async function clientLoader() {
|
export async function clientLoader({
|
||||||
const {channels, setChannels} = usePrivateChannelsStore.getState()
|
params: { serverId }
|
||||||
|
}: Route.ClientLoaderArgs) {
|
||||||
|
const { channels, addChannels, addServer } = useServerChannelsStore.getState()
|
||||||
|
|
||||||
if (!channels || channels.length === 0) {
|
const server = useServerListStore.getState().servers[serverId as ServerId] || undefined
|
||||||
const channels = await import("~/lib/api/client/user").then(m => m.default.channels())
|
|
||||||
setChannels(channels)
|
if (!server) {
|
||||||
|
return redirect("/app/@me")
|
||||||
|
}
|
||||||
|
|
||||||
|
const channelList = channels[serverId as ServerId]
|
||||||
|
|
||||||
|
if (channelList === undefined) {
|
||||||
|
const channels = await import("~/lib/api/client/server").then(m => m.default.listChannels(serverId as ServerId))
|
||||||
|
addServer(serverId as ServerId)
|
||||||
|
addChannels(channels)
|
||||||
|
|
||||||
|
useGatewayStore.getState().requestVoiceStates(serverId as ServerId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function ListComponent() {
|
export function shouldRevalidate(
|
||||||
const channels = []
|
arg: ShouldRevalidateFunctionArgs
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
const serverId = useParams<{ serverId: string }>().serverId!
|
function ListComponent() {
|
||||||
const server = useServerListStore(state => state.servers.get(serverId))
|
const serverId = useParams<{ serverId: ServerId }>().serverId!
|
||||||
|
const currentUserId = useUsersStore(state => state.currentUserId)
|
||||||
|
const onOpen = useModalStore(state => state.onOpen)
|
||||||
|
|
||||||
|
const server = useServerListStore(useShallow(state => state.servers[serverId] || null))
|
||||||
|
|
||||||
|
const channels = Array.from(useServerChannelsStore(useShallow(state => Object.values(state.channels[serverId] || {}))))
|
||||||
|
|
||||||
|
if (!server) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col">
|
<div className="h-full flex flex-col">
|
||||||
<div className="w-full min-h-12">
|
<div className="w-full min-h-12">
|
||||||
<div className="border-b-2 h-full flex items-center justify-center">
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<div className="border-b-2 h-full flex items-center justify-center cursor-pointer">
|
||||||
{server?.name}
|
{server?.name}
|
||||||
</div>
|
</div>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent>
|
||||||
|
<DropdownMenuItem onClick={() => onOpen(ModalType.CREATE_SERVER_INVITE, { serverId })}>Create invite</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => onOpen(ModalType.CREATE_SERVER_CHANNEL, { serverId })}>Create channel</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{currentUserId === server.ownerId &&
|
||||||
|
<DropdownMenuItem variant="destructive" onClick={() => onOpen(ModalType.DELETE_SERVER_CONFIRM, { serverId })}>Delete</DropdownMenuItem>}
|
||||||
|
{currentUserId !== server.ownerId &&
|
||||||
|
<DropdownMenuItem variant="destructive">Leave</DropdownMenuItem>}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ScrollArea className="overflow-auto" scrollbarSize="narrow">
|
<ScrollArea className="overflow-auto" scrollbarSize="narrow">
|
||||||
<div className="p-2 flex flex-col gap-1 h-full">
|
<div className="p-2 flex flex-col gap-1 h-full">
|
||||||
{channels.sort((a, b) => (a.lastMessageId ?? a.id) < (b.lastMessageId ?? b.id) ? 1 : -1).map((channel, _) => (
|
{channels.sort((a, b) => (a.lastMessageId ?? a.id) < (b.lastMessageId ?? b.id) ? 1 : -1).map((channel, _) => (
|
||||||
<>
|
<React.Fragment key={channel.id}>
|
||||||
<ChannelListItem channel={channel} />
|
<ServerChannelListItem channel={channel} />
|
||||||
</>
|
</React.Fragment>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
@@ -44,7 +91,17 @@ export const handle = {
|
|||||||
listComponent: <ListComponent />
|
listComponent: <ListComponent />
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Layout() {
|
export default function Layout(
|
||||||
|
{ params: { serverId } }: Route.ComponentProps
|
||||||
|
) {
|
||||||
|
const server = useServerListStore(useShallow(state => state.servers[serverId!] || null))
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
if (!server) {
|
||||||
|
setTimeout(() => navigate("/app/@me"), 0)
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
|||||||
103
app/routes/app/settings.tsx
Normal file
103
app/routes/app/settings.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { LogOutIcon, PencilIcon, UserIcon } from "lucide-react";
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Input } from "~/components/ui/input";
|
||||||
|
import { useUsersStore } from "~/stores/users-store";
|
||||||
|
|
||||||
|
// Note: This is a mockup based on the provided store structure
|
||||||
|
export default function Settings() {
|
||||||
|
const user = useUsersStore(state => state.getCurrentUser());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen">
|
||||||
|
{/* Sidebar */}
|
||||||
|
<div className="w-64 border-r p-6 flex flex-col">
|
||||||
|
<h1 className="text-2xl font-bold mb-6">Settings</h1>
|
||||||
|
|
||||||
|
<Button variant="outline" className="justify-start mb-2 w-full">
|
||||||
|
<UserIcon className="mr-2 h-4 w-4" />
|
||||||
|
Profile
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="mt-auto">
|
||||||
|
<Button variant="destructive" className="w-full">
|
||||||
|
<LogOutIcon className="mr-2 h-4 w-4" />
|
||||||
|
Logout
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<div className="flex-1 p-8">
|
||||||
|
<div className="max-w-2xl mx-auto">
|
||||||
|
<div className="flex flex-col items-center mb-8">
|
||||||
|
<Avatar className="h-24 w-24 mb-4">
|
||||||
|
<AvatarImage src={user?.avatarUrl} alt={user?.displayName} />
|
||||||
|
<AvatarFallback className="text-yellow-500">
|
||||||
|
<UserIcon className="h-12 w-12" />
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<PencilIcon className="mr-2 h-4 w-4" />
|
||||||
|
Change
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="name" className="block text-sm mb-1">
|
||||||
|
Username
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
value={user?.username}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="displayName" className="block text-sm mb-1">
|
||||||
|
Display name
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="displayName"
|
||||||
|
name="displayName"
|
||||||
|
value={user?.displayName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email" className="block text-sm mb-1">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
value={user?.email}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="block text-sm mb-1">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" className="mt-4">
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,7 +3,8 @@ import { AxiosError } from "axios";
|
|||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { Link, redirect, useNavigate } from "react-router";
|
import { Link, redirect, useNavigate } from "react-router";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { PasswordInput } from "~/components/password-input";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
|
import { PasswordInput } from "~/components/custom-ui/password-input";
|
||||||
import { ThemeToggle } from "~/components/theme/theme-toggle";
|
import { ThemeToggle } from "~/components/theme/theme-toggle";
|
||||||
import { Button, buttonVariants } from "~/components/ui/button";
|
import { Button, buttonVariants } from "~/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "~/components/ui/card";
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
@@ -17,8 +18,8 @@ import {
|
|||||||
} from "~/components/ui/form";
|
} from "~/components/ui/form";
|
||||||
import { Input } from "~/components/ui/input";
|
import { Input } from "~/components/ui/input";
|
||||||
import auth from "~/lib/api/client/auth";
|
import auth from "~/lib/api/client/auth";
|
||||||
import { useTokenStore } from "~/store/token";
|
import { useTokenStore } from "~/stores/token-store";
|
||||||
import { useUserStore } from "~/store/user";
|
import { useUsersStore } from "~/stores/users-store";
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
username: z.string(),
|
username: z.string(),
|
||||||
@@ -46,7 +47,12 @@ export async function clientLoader() {
|
|||||||
export default function Login() {
|
export default function Login() {
|
||||||
let navigate = useNavigate()
|
let navigate = useNavigate()
|
||||||
let setToken = useTokenStore(state => state.setToken)
|
let setToken = useTokenStore(state => state.setToken)
|
||||||
let setUser = useUserStore(state => state.setUser)
|
const { setCurrentUserId, addUser } = useUsersStore(useShallow(state => {
|
||||||
|
return {
|
||||||
|
setCurrentUserId: state.setCurrentUserId,
|
||||||
|
addUser: state.addUser
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
const form = useForm<z.infer<typeof schema>>({
|
const form = useForm<z.infer<typeof schema>>({
|
||||||
resolver: zodResolver(schema),
|
resolver: zodResolver(schema),
|
||||||
@@ -56,7 +62,8 @@ export default function Login() {
|
|||||||
const response = await auth.login(values)
|
const response = await auth.login(values)
|
||||||
|
|
||||||
setToken(response.token)
|
setToken(response.token)
|
||||||
setUser(response.user)
|
setCurrentUserId(response.user.id)
|
||||||
|
addUser(response.user)
|
||||||
|
|
||||||
navigate("/app")
|
navigate("/app")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
|||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { Link, useNavigate } from "react-router";
|
import { Link, useNavigate } from "react-router";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { PasswordInput } from "~/components/password-input";
|
import { PasswordInput } from "~/components/custom-ui/password-input";
|
||||||
import { ThemeToggle } from "~/components/theme/theme-toggle";
|
import { ThemeToggle } from "~/components/theme/theme-toggle";
|
||||||
import { Button, buttonVariants } from "~/components/ui/button";
|
import { Button, buttonVariants } from "~/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "~/components/ui/card";
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
// ~/store/active-voice-channel.ts
|
|
||||||
import { create } from 'zustand';
|
|
||||||
import type { Uuid } from '~/lib/api/types';
|
|
||||||
|
|
||||||
interface ActiveVoiceChannelState {
|
|
||||||
serverId: Uuid | null;
|
|
||||||
channelId: Uuid | null;
|
|
||||||
// User's explicit intent to be in voice, helps differentiate between just selecting a channel and actually joining
|
|
||||||
isVoiceActive: boolean;
|
|
||||||
setActiveVoiceChannel: (serverId: Uuid | null, channelId: Uuid | null) => void;
|
|
||||||
toggleVoiceActivation: (activate: boolean) => void; // To explicitly join/leave voice for the selected channel
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useActiveVoiceChannelStore = create<ActiveVoiceChannelState>((set, get) => ({
|
|
||||||
serverId: null,
|
|
||||||
channelId: null,
|
|
||||||
isVoiceActive: false,
|
|
||||||
setActiveVoiceChannel: (serverId, channelId) => {
|
|
||||||
const current = get();
|
|
||||||
// If changing channels, implicitly deactivate voice from the old one.
|
|
||||||
// The manager will then pick up the new channel and wait for isVoiceActive.
|
|
||||||
if (current.channelId !== channelId && current.isVoiceActive) {
|
|
||||||
set({ serverId, channelId, isVoiceActive: false }); // User needs to explicitly activate new channel
|
|
||||||
} else {
|
|
||||||
set({ serverId, channelId });
|
|
||||||
}
|
|
||||||
console.log(`ActiveVoiceChannel: Selected Server: ${serverId}, Channel: ${channelId}`);
|
|
||||||
},
|
|
||||||
toggleVoiceActivation: (activate) => {
|
|
||||||
const { serverId, channelId } = get();
|
|
||||||
if (activate && (!serverId || !channelId)) {
|
|
||||||
console.warn("ActiveVoiceChannel: Cannot activate voice without a selected server/channel.");
|
|
||||||
set({ isVoiceActive: false });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
set({ isVoiceActive: activate });
|
|
||||||
console.log(`ActiveVoiceChannel: Voice activation set to ${activate} for ${serverId}/${channelId}`);
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
@@ -1,435 +0,0 @@
|
|||||||
import { create } from 'zustand';
|
|
||||||
import type {
|
|
||||||
ClientMessage,
|
|
||||||
EventServerPayload,
|
|
||||||
ServerMessage,
|
|
||||||
VoiceStateUpdateClientPayload,
|
|
||||||
WebSocketStatus
|
|
||||||
} from '~/lib/websocket/gateway.types'; // Adjust path as needed
|
|
||||||
import { useServerListStore } from './server-list'; // Adjust path as needed
|
|
||||||
|
|
||||||
// --- Configuration ---
|
|
||||||
const WEBSOCKET_URL = "ws://localhost:12345/gateway/ws";
|
|
||||||
const INITIAL_RECONNECT_DELAY = 1000;
|
|
||||||
const MAX_RECONNECT_DELAY = 30000;
|
|
||||||
const BACKOFF_FACTOR = 2;
|
|
||||||
const MAX_RECONNECT_ATTEMPTS = 10;
|
|
||||||
|
|
||||||
let isConnectingInProgress = false;
|
|
||||||
let currentConnectCallId = 0;
|
|
||||||
|
|
||||||
function handleBusinessEvent(event: EventServerPayload) {
|
|
||||||
console.debug("WS: Received business event:", event.type, event.data);
|
|
||||||
switch (event.type) {
|
|
||||||
case "ADD_SERVER":
|
|
||||||
useServerListStore.getState().addServer(event.data.server);
|
|
||||||
break;
|
|
||||||
case "REMOVE_SERVER":
|
|
||||||
useServerListStore.getState().removeServer(event.data.serverId);
|
|
||||||
break;
|
|
||||||
// Add other event types from your application
|
|
||||||
case "VOICE_SERVER_UPDATE":
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
// This ensures all event types are handled if EventServerPayload is a strict union
|
|
||||||
const _exhaustiveCheck: never = event;
|
|
||||||
console.warn("WS: Received unhandled business event type:", _exhaustiveCheck);
|
|
||||||
return _exhaustiveCheck;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GatewayWebSocketState {
|
|
||||||
status: WebSocketStatus;
|
|
||||||
userId: string | null;
|
|
||||||
sessionKey: string | null;
|
|
||||||
lastError: string | null;
|
|
||||||
connect: (getToken: () => string | null | Promise<string | null>) => void;
|
|
||||||
disconnect: (intentional?: boolean) => void;
|
|
||||||
sendVoiceStateUpdate: (payload: VoiceStateUpdateClientPayload) => boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
let socket: WebSocket | null = null;
|
|
||||||
let pingIntervalId: ReturnType<typeof setInterval> | null = null;
|
|
||||||
let reconnectTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
let reconnectAttempts = 0;
|
|
||||||
let getTokenFunc: (() => string | null | Promise<string | null>) | null = null;
|
|
||||||
let isIntentionalDisconnect = false;
|
|
||||||
let serverReportedError = false; // Flag: true if server sent an ERROR message
|
|
||||||
|
|
||||||
export const useGatewayWebSocketStore = create<GatewayWebSocketState>((set, get) => {
|
|
||||||
const clearPingInterval = () => {
|
|
||||||
if (pingIntervalId) clearInterval(pingIntervalId);
|
|
||||||
pingIntervalId = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearReconnectTimeout = () => {
|
|
||||||
if (reconnectTimeoutId) clearTimeout(reconnectTimeoutId);
|
|
||||||
reconnectTimeoutId = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const sendWebSocketMessage = (message: ClientMessage): boolean => {
|
|
||||||
if (socket?.readyState === WebSocket.OPEN) {
|
|
||||||
console.debug("WS: Sending message:", message);
|
|
||||||
socket.send(JSON.stringify(message));
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
console.warn(`WS: Cannot send ${message.type}. WebSocket not open (state: ${socket?.readyState}).`);
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetConnectingFlag = () => {
|
|
||||||
if (isConnectingInProgress) {
|
|
||||||
console.debug("WS: Resetting isConnectingInProgress flag.");
|
|
||||||
isConnectingInProgress = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOpen = async () => {
|
|
||||||
console.log("WS: Connection established. Authenticating...");
|
|
||||||
set({ status: "AUTHENTICATING", lastError: null });
|
|
||||||
|
|
||||||
if (!getTokenFunc) {
|
|
||||||
console.error("WS: Auth failed. Token getter missing.");
|
|
||||||
get().disconnect(false); // Non-intentional, treat as error
|
|
||||||
set({ lastError: "Token provider missing for authentication." });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const token = await getTokenFunc();
|
|
||||||
if (!token) {
|
|
||||||
console.error("WS: Auth failed. No token from getter.");
|
|
||||||
isIntentionalDisconnect = true; // Prevent auto-reconnect for this specific failure
|
|
||||||
get().disconnect(true);
|
|
||||||
set({ lastError: "Authentication token not available." });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
sendWebSocketMessage({ type: "AUTHENTICATE", data: { token } });
|
|
||||||
} catch (error) {
|
|
||||||
console.error("WS: Error getting token for auth:", error);
|
|
||||||
isIntentionalDisconnect = true;
|
|
||||||
get().disconnect(true);
|
|
||||||
set({ lastError: "Failed to retrieve authentication token." });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMessage = async (event: MessageEvent) => {
|
|
||||||
try {
|
|
||||||
const message = JSON.parse(event.data as string) as ServerMessage;
|
|
||||||
console.debug("WS: Received message:", message);
|
|
||||||
|
|
||||||
switch (message.type) {
|
|
||||||
case "AUTHENTICATE_ACCEPTED":
|
|
||||||
resetConnectingFlag();
|
|
||||||
reconnectAttempts = 0;
|
|
||||||
clearReconnectTimeout();
|
|
||||||
set({ status: "CONNECTED", userId: message.data.userId, sessionKey: message.data.sessionKey, lastError: null });
|
|
||||||
console.log(`WS: Authenticated as user ${message.data.userId}.`);
|
|
||||||
|
|
||||||
break;
|
|
||||||
case "AUTHENTICATE_DENIED":
|
|
||||||
resetConnectingFlag();
|
|
||||||
console.warn("WS: Authentication denied by server.");
|
|
||||||
isIntentionalDisconnect = true; // Prevent auto-reconnect
|
|
||||||
serverReportedError = false; // This is an auth denial, not a runtime server error
|
|
||||||
get().disconnect(true); // Disconnect, don't retry
|
|
||||||
set({ status: "ERROR", lastError: "Authentication denied by server." });
|
|
||||||
break;
|
|
||||||
case "EVENT":
|
|
||||||
handleBusinessEvent(message.data.event);
|
|
||||||
break;
|
|
||||||
case "ERROR":
|
|
||||||
resetConnectingFlag();
|
|
||||||
console.error(`WS: Server reported error. Code: ${message.data.code}`);
|
|
||||||
serverReportedError = true; // CRITICAL: Set flag
|
|
||||||
isIntentionalDisconnect = true; // Treat as a definitive stop from server
|
|
||||||
set({
|
|
||||||
status: "ERROR",
|
|
||||||
lastError: `Server error (${message.data.code})`,
|
|
||||||
userId: null, // Clear user session on server error
|
|
||||||
sessionKey: null, // Clear session key on server error
|
|
||||||
});
|
|
||||||
// Server is expected to close the connection. `onclose` will use `serverReportedError`.
|
|
||||||
// Ping interval will be cleared by `cleanupConnection` via `onclose`.
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
const _exhaustiveCheck: never = message;
|
|
||||||
console.warn("WS: Received unknown server message type:", _exhaustiveCheck);
|
|
||||||
set({ lastError: "Received unknown message type from server." });
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("WS: Failed to parse or handle server message:", error, "Raw data:", event.data);
|
|
||||||
set({ lastError: "Failed to parse server message." });
|
|
||||||
// This is a client-side parsing error. We might want to disconnect if this happens.
|
|
||||||
// serverReportedError = true; // Consider this a fatal client error
|
|
||||||
// isIntentionalDisconnect = true;
|
|
||||||
// get().disconnect(false); // Or true depending on desired reconnect behavior
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleError = (event: Event) => {
|
|
||||||
resetConnectingFlag();
|
|
||||||
// This event is often vague and usually followed by onclose.
|
|
||||||
console.error("WS: WebSocket error event occurred:", event);
|
|
||||||
// Avoid changing status directly if onclose will handle it, to prevent state flickering.
|
|
||||||
// But, set lastError as it might be the only indication of a problem if onclose is not detailed.
|
|
||||||
set(state => ({ lastError: state.lastError || `WebSocket error: ${event.type || 'Unknown error'}` }));
|
|
||||||
// `onclose` will call `cleanupConnection`.
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClose = (event: CloseEvent) => {
|
|
||||||
const wasInProgress = isConnectingInProgress;
|
|
||||||
resetConnectingFlag();
|
|
||||||
console.log(
|
|
||||||
`WS: Connection closed. Code: ${event.code}, Reason: "${event.reason || 'N/A'}", Clean: ${event.wasClean}, Intentional: ${isIntentionalDisconnect}, ServerError: ${serverReportedError}, WasInProgress: ${wasInProgress}`
|
|
||||||
);
|
|
||||||
|
|
||||||
const wasNormalClosure = event.code === 1000 || event.code === 1001; // 1001: Going Away
|
|
||||||
const shouldAttemptReconnect = !isIntentionalDisconnect && !serverReportedError && !wasNormalClosure;
|
|
||||||
|
|
||||||
const previousStatus = get().status;
|
|
||||||
cleanupConnection(shouldAttemptReconnect);
|
|
||||||
|
|
||||||
if (!shouldAttemptReconnect) {
|
|
||||||
console.log("WS: No reconnect attempt scheduled.");
|
|
||||||
if (serverReportedError) {
|
|
||||||
// State already set to ERROR by handleMessage or will be set by cleanup.
|
|
||||||
if (get().status !== 'ERROR') set({ status: "ERROR", userId: null, sessionKey: null }); // Ensure it
|
|
||||||
} else if (isIntentionalDisconnect && previousStatus !== "ERROR") {
|
|
||||||
set({ status: "DISCONNECTED", userId: null, sessionKey: null });
|
|
||||||
} else if (wasNormalClosure && previousStatus !== "ERROR") {
|
|
||||||
set({ status: "DISCONNECTED", userId: null, sessionKey: null });
|
|
||||||
} else if (previousStatus !== "ERROR" && previousStatus !== "DISCONNECTED" && previousStatus !== "IDLE") {
|
|
||||||
set({ status: "DISCONNECTED", userId: null, sessionKey: null, lastError: get().lastError || "Connection closed unexpectedly." });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// If shouldAttemptReconnect, cleanupConnection -> scheduleReconnect sets status to RECONNECTING.
|
|
||||||
};
|
|
||||||
|
|
||||||
const cleanupConnection = (attemptReconnect: boolean) => {
|
|
||||||
resetConnectingFlag();
|
|
||||||
console.debug(`WS: Cleaning up connection. Attempt reconnect: ${attemptReconnect}`);
|
|
||||||
clearPingInterval();
|
|
||||||
clearReconnectTimeout();
|
|
||||||
|
|
||||||
if (socket) {
|
|
||||||
socket.onopen = null; socket.onmessage = null; socket.onerror = null; socket.onclose = null;
|
|
||||||
if (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING) {
|
|
||||||
try { socket.close(1000, "Client cleanup"); }
|
|
||||||
catch (e) { console.warn("WS: Error closing socket during cleanup:", e); }
|
|
||||||
}
|
|
||||||
socket = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (attemptReconnect) {
|
|
||||||
if (!serverReportedError) { // Don't retry if server explicitly said ERROR
|
|
||||||
scheduleReconnect();
|
|
||||||
} else {
|
|
||||||
console.warn("WS: Reconnect attempt skipped due to server-reported error.");
|
|
||||||
if (get().status !== 'ERROR') {
|
|
||||||
set({ status: "ERROR", userId: null, sessionKey: null, lastError: get().lastError || "Server error, stopping." });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const scheduleReconnect = () => {
|
|
||||||
if (get().status === "ERROR" && serverReportedError) { // Double check
|
|
||||||
console.log("WS: Reconnect inhibited by server-reported error state.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
|
|
||||||
console.warn(`WS: Max reconnect attempts (${MAX_RECONNECT_ATTEMPTS}) reached.`);
|
|
||||||
set({ status: "ERROR", lastError: "Reconnection failed after multiple attempts.", userId: null, sessionKey: null });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const delay = Math.min(INITIAL_RECONNECT_DELAY * Math.pow(BACKOFF_FACTOR, reconnectAttempts), MAX_RECONNECT_DELAY);
|
|
||||||
reconnectAttempts++;
|
|
||||||
console.log(`WS: Scheduling reconnect attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS} in ${delay / 1000}s...`);
|
|
||||||
set(state => ({ status: "RECONNECTING", lastError: state.lastError }));
|
|
||||||
|
|
||||||
reconnectTimeoutId = setTimeout(() => {
|
|
||||||
if (get().status !== "RECONNECTING") { // Status might have changed (e.g. explicit disconnect)
|
|
||||||
console.log("WS: Reconnect attempt cancelled (status changed).");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_connect();
|
|
||||||
}, delay);
|
|
||||||
};
|
|
||||||
|
|
||||||
const _connect = async () => { // Internal connect, used for retries and initial
|
|
||||||
const callId = currentConnectCallId;
|
|
||||||
if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) {
|
|
||||||
console.warn(`WS (_connect [${callId}]): Called but WebSocket already open/connecting.`);
|
|
||||||
if (socket.readyState === WebSocket.OPEN && get().status === 'CONNECTED') resetConnectingFlag();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isConnectingInProgress) {
|
|
||||||
console.warn(`WS (_connect [${callId}]): Aborted, another connection attempt is already in progress.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Critical: If serverReportedError is true, _connect (retry mechanism) should not proceed.
|
|
||||||
// Only an explicit call to the public `connect()` (which resets serverReportedError) should bypass this.
|
|
||||||
if (serverReportedError) {
|
|
||||||
resetConnectingFlag();
|
|
||||||
console.warn("WS: _connect aborted due to server-reported error. Explicit connect() needed to retry.");
|
|
||||||
if (get().status !== 'ERROR') set({ status: 'ERROR', lastError: get().lastError || 'Server reported an error.' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
clearReconnectTimeout();
|
|
||||||
|
|
||||||
if (!getTokenFunc) {
|
|
||||||
console.error("WS: Cannot connect. Token getter missing.");
|
|
||||||
set({ status: "ERROR", lastError: "Token provider missing." }); // Terminal for this attempt cycle
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`WS (_connect [${callId}]): Attempting to connect to ${WEBSOCKET_URL}... (Overall Attempt ${reconnectAttempts + 1})`);
|
|
||||||
isConnectingInProgress = true;
|
|
||||||
|
|
||||||
try { // Pre-flight check for token availability
|
|
||||||
const token = await getTokenFunc();
|
|
||||||
if (!token) {
|
|
||||||
console.warn("WS: No token available. Aborting connection attempt cycle.");
|
|
||||||
set({ status: "DISCONNECTED", lastError: "Authentication token unavailable.", userId: null, sessionKey: null });
|
|
||||||
isIntentionalDisconnect = true; // Stop retrying if token is consistently unavailable
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("WS: Error getting token before connecting:", error);
|
|
||||||
set({ status: "ERROR", lastError: "Failed to retrieve authentication token.", userId: null, sessionKey: null });
|
|
||||||
isIntentionalDisconnect = true; // Stop retrying for this
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`WS: Attempting to connect to ${WEBSOCKET_URL}... (Attempt ${reconnectAttempts + 1})`);
|
|
||||||
// Preserve lastError if we are RECONNECTING, otherwise clear it for a fresh CONNECTING attempt
|
|
||||||
set(state => ({ status: "CONNECTING", lastError: state.status === 'RECONNECTING' ? state.lastError : null }));
|
|
||||||
|
|
||||||
try {
|
|
||||||
socket = new WebSocket(WEBSOCKET_URL);
|
|
||||||
// Flags (isIntentionalDisconnect, serverReportedError) are reset by public `connect()`
|
|
||||||
socket.onopen = handleOpen;
|
|
||||||
socket.onmessage = handleMessage;
|
|
||||||
socket.onerror = handleError;
|
|
||||||
socket.onclose = handleClose;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("WS: Failed to create WebSocket instance:", error);
|
|
||||||
set({ status: "ERROR", lastError: "Failed to initialize WebSocket connection." });
|
|
||||||
socket = null;
|
|
||||||
resetConnectingFlag();
|
|
||||||
cleanupConnection(!isIntentionalDisconnect && !serverReportedError); // Attempt reconnect if appropriate
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: "IDLE",
|
|
||||||
userId: null,
|
|
||||||
sessionKey: null,
|
|
||||||
lastError: null,
|
|
||||||
|
|
||||||
connect: (newGetTokenFunc) => {
|
|
||||||
const localCallId = ++currentConnectCallId;
|
|
||||||
console.log(`WS: Explicit connect requested (Call ID: ${localCallId}). Current status: ${get().status}, InProgress: ${isConnectingInProgress}`);
|
|
||||||
|
|
||||||
// If already connected and socket is open, do nothing.
|
|
||||||
if (get().status === "CONNECTED" && socket?.readyState === WebSocket.OPEN) {
|
|
||||||
console.warn(`WS (connect [${localCallId}]): Already connected.`);
|
|
||||||
resetConnectingFlag(); // Ensure it's false if we are truly connected.
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If a connection is actively being established by another call, log and return.
|
|
||||||
// Allow if status is IDLE/DISCONNECTED/ERROR, as these are states where a new attempt is valid.
|
|
||||||
if (isConnectingInProgress && !['IDLE', 'DISCONNECTED', 'ERROR', 'RECONNECTING'].includes(get().status)) {
|
|
||||||
console.warn(`WS (connect [${localCallId}]): Connect called while a connection attempt is already in progress and status is ${get().status}.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// If status is CONNECTING/AUTHENTICATING but isConnectingInProgress is false (e.g., after a bug),
|
|
||||||
// allow cleanup and new attempt.
|
|
||||||
|
|
||||||
// Reset flags for a *new* explicit connection sequence
|
|
||||||
isIntentionalDisconnect = false;
|
|
||||||
serverReportedError = false;
|
|
||||||
reconnectAttempts = 0;
|
|
||||||
// isConnectingInProgress will be set by _connect if it proceeds.
|
|
||||||
// Resetting it here might be too early if _connect has checks that depend on previous state of isConnectingInProgress.
|
|
||||||
// Instead, ensure _connect and its outcomes (open, error, close) reliably reset it.
|
|
||||||
|
|
||||||
clearReconnectTimeout();
|
|
||||||
|
|
||||||
if (socket) { // If there's an old socket (e.g. from a failed/interrupted attempt)
|
|
||||||
console.warn(`WS (connect [${localCallId}]): Explicit connect called with existing socket (state: ${socket.readyState}). Cleaning up old one.`);
|
|
||||||
cleanupConnection(false); // This will call resetConnectingFlag()
|
|
||||||
}
|
|
||||||
// At this point, isConnectingInProgress should be false due to cleanupConnection or initial state.
|
|
||||||
|
|
||||||
getTokenFunc = newGetTokenFunc;
|
|
||||||
_connect(); // _connect will set isConnectingInProgress = true if it starts.
|
|
||||||
},
|
|
||||||
|
|
||||||
disconnect: (intentional = true) => {
|
|
||||||
const callId = currentConnectCallId; // For logging
|
|
||||||
console.log(`WS (disconnect [${callId}]): Disconnect requested. Intentional: ${intentional}, InProgress: ${isConnectingInProgress}`);
|
|
||||||
isIntentionalDisconnect = intentional;
|
|
||||||
if (intentional) {
|
|
||||||
serverReportedError = false;
|
|
||||||
}
|
|
||||||
// No matter what, a disconnect means any "connecting in progress" is now void.
|
|
||||||
resetConnectingFlag(); // Crucial: stop any perceived ongoing connection attempt.
|
|
||||||
reconnectAttempts = 0;
|
|
||||||
|
|
||||||
const currentLastError = get().lastError;
|
|
||||||
const wasServerError = serverReportedError || (currentLastError && currentLastError.startsWith("Server error"));
|
|
||||||
const wasAuthDenied = currentLastError === "Authentication denied by server.";
|
|
||||||
|
|
||||||
cleanupConnection(false); // Clean up, do not attempt reconnect
|
|
||||||
|
|
||||||
set({
|
|
||||||
status: "DISCONNECTED",
|
|
||||||
userId: null,
|
|
||||||
sessionKey: null,
|
|
||||||
// Preserve significant errors (server error, auth denied) even on user disconnect.
|
|
||||||
// Otherwise, set a generic disconnect message.
|
|
||||||
lastError: (wasServerError || wasAuthDenied) ? currentLastError : (intentional ? "User disconnected" : "Disconnected"),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
sendVoiceStateUpdate: (payload: VoiceStateUpdateClientPayload) => {
|
|
||||||
return sendWebSocketMessage({ type: "VOICE_STATE_UPDATE", data: payload });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Optional: Online/Offline listeners. Consider placing in a React component
|
|
||||||
// that can supply `getTokenFunc` more naturally upon reconnection.
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
const tryAutoConnectOnline = () => {
|
|
||||||
const { status, connect } = useGatewayWebSocketStore.getState();
|
|
||||||
console.log("WS: Browser online event detected.");
|
|
||||||
if (status === 'DISCONNECTED' || status === 'ERROR' || status === 'RECONNECTING') {
|
|
||||||
if (!serverReportedError) { // Don't auto-connect if last state was a server-reported fatal error
|
|
||||||
console.log("WS: Attempting to connect after coming online.");
|
|
||||||
if (getTokenFunc) { // Check if we still have a token getter
|
|
||||||
connect(getTokenFunc);
|
|
||||||
} else {
|
|
||||||
console.warn("WS: Cannot auto-connect on 'online': token getter not available. App needs to call connect() again.");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.log("WS: Browser online, but previous server-reported error prevents auto-reconnect. Manual connect needed.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
window.addEventListener('online', tryAutoConnectOnline);
|
|
||||||
|
|
||||||
const handleAppClose = () => { // Best-effort for page unload
|
|
||||||
const { disconnect, status } = useGatewayWebSocketStore.getState();
|
|
||||||
if (status !== "IDLE" && status !== "DISCONNECTED") {
|
|
||||||
disconnect(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
window.addEventListener('beforeunload', handleAppClose);
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import { create } from 'zustand'
|
|
||||||
import type { RecipientChannel } from '~/lib/api/types'
|
|
||||||
|
|
||||||
type PrivateChannelsStore = {
|
|
||||||
channels: RecipientChannel[]
|
|
||||||
setChannels: (channels: RecipientChannel[]) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export const usePrivateChannelsStore = create<PrivateChannelsStore>()(
|
|
||||||
(set, get) => ({
|
|
||||||
channels: [],
|
|
||||||
setChannels: (channels: RecipientChannel[]) => set({ channels }),
|
|
||||||
})
|
|
||||||
)
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
|
|
||||||
// type ServerChannelListStore = {
|
|
||||||
// servers: Map<Uuid, Map<Uuid, Channel>>
|
|
||||||
// setServers: (newServers: Server[]) => void
|
|
||||||
// addServer: (server: Server) => void
|
|
||||||
// removeServer: (serverId: Uuid) => void
|
|
||||||
// }
|
|
||||||
|
|
||||||
// export const useServerListStore = create<ServerChannelListStore>(
|
|
||||||
// (set, get) => ({
|
|
||||||
// servers: new Map<Uuid, Server>(),
|
|
||||||
// setServers: (newServers: Server[]) => set({ servers: new Map(newServers.map(server => [server.id, server])) }),
|
|
||||||
// addServer: (server: Server) => set({ servers: new Map(get().servers.set(server.id, server)) }),
|
|
||||||
// removeServer: (serverId: Uuid) => set((state) => {
|
|
||||||
// const newServers = new Map(state.servers)
|
|
||||||
// newServers.delete(serverId)
|
|
||||||
// return { servers: newServers }
|
|
||||||
// }),
|
|
||||||
// })
|
|
||||||
// )
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import { create } from 'zustand'
|
|
||||||
import { type Server, type Uuid } from '~/lib/api/types'
|
|
||||||
|
|
||||||
type ServerListStore = {
|
|
||||||
servers: Map<Uuid, Server>
|
|
||||||
setServers: (newServers: Server[]) => void
|
|
||||||
addServer: (server: Server) => void
|
|
||||||
removeServer: (serverId: Uuid) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useServerListStore = create<ServerListStore>(
|
|
||||||
(set, get) => ({
|
|
||||||
servers: new Map<Uuid, Server>(),
|
|
||||||
setServers: (newServers: Server[]) => set({ servers: new Map(newServers.map(server => [server.id, server])) }),
|
|
||||||
addServer: (server: Server) => set({ servers: new Map(get().servers.set(server.id, server)) }),
|
|
||||||
removeServer: (serverId: Uuid) => set((state) => {
|
|
||||||
const newServers = new Map(state.servers)
|
|
||||||
newServers.delete(serverId)
|
|
||||||
return { servers: newServers }
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
)
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import { create } from 'zustand'
|
|
||||||
import type { User } from '~/lib/api/types'
|
|
||||||
|
|
||||||
type UserStore = {
|
|
||||||
user?: User
|
|
||||||
setUser: (user: User) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useUserStore = create<UserStore>()(
|
|
||||||
(set, get) => ({
|
|
||||||
user: undefined,
|
|
||||||
setUser: (user: User) => set({ user }),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
@@ -1,408 +0,0 @@
|
|||||||
import { create } from 'zustand';
|
|
||||||
import type { SdpAnswerVoicePayload, VoiceClientMessage, VoiceServerMessage, WebSocketStatus } from '~/lib/websocket/voice.types';
|
|
||||||
|
|
||||||
// Callbacks for application-specific events
|
|
||||||
interface VoiceWebSocketCallbacks {
|
|
||||||
onSdpAnswer?: (payload: SdpAnswerVoicePayload) => void;
|
|
||||||
// onBrowserOnline?: () => void; // Optional: If app wants to be notified by 'online' event
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Store State ---
|
|
||||||
export interface VoiceWebSocketState {
|
|
||||||
status: WebSocketStatus;
|
|
||||||
lastError: string | null;
|
|
||||||
connect: (
|
|
||||||
webSocketUrl: string,
|
|
||||||
getToken: () => string | null | Promise<string | null>
|
|
||||||
) => void;
|
|
||||||
disconnect: (intentional?: boolean) => void;
|
|
||||||
sendSdpOffer: (sdp: RTCSessionDescriptionInit) => boolean;
|
|
||||||
setCallbacks: (callbacks: VoiceWebSocketCallbacks) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Module-level state (managed by Zustand store closure) ---
|
|
||||||
let socket: WebSocket | null = null;
|
|
||||||
let getTokenFunc: (() => string | null | Promise<string | null>) | null = null;
|
|
||||||
let currentWebSocketUrl: string | null = null;
|
|
||||||
|
|
||||||
let isIntentionalDisconnect = false;
|
|
||||||
let serverReportedError = false; // Flag: true if server sent an ERROR message
|
|
||||||
let isConnectingInProgress = false; // Flag to prevent multiple concurrent connect attempts
|
|
||||||
let currentConnectCallId = 0; // For debugging concurrent calls
|
|
||||||
|
|
||||||
let voiceCallbacks: VoiceWebSocketCallbacks = {};
|
|
||||||
|
|
||||||
export const useVoiceWebSocketStore = create<VoiceWebSocketState>((set, get) => {
|
|
||||||
|
|
||||||
const sendWebSocketMessage = (message: VoiceClientMessage): boolean => {
|
|
||||||
if (socket?.readyState === WebSocket.OPEN) {
|
|
||||||
console.debug("VoiceWS: Sending message:", message.type, message.data);
|
|
||||||
try {
|
|
||||||
socket.send(JSON.stringify(message));
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("VoiceWS: Error sending message:", error, message);
|
|
||||||
// This is a critical error during send, transition to ERROR
|
|
||||||
set({ status: "ERROR", lastError: "Failed to send message due to a WebSocket error." });
|
|
||||||
cleanupConnectionInternals(); // Clean up the broken socket
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.warn(`VoiceWS: Cannot send ${message.type}. WebSocket not open (state: ${socket?.readyState}).`);
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetConnectingFlag = () => {
|
|
||||||
if (isConnectingInProgress) {
|
|
||||||
console.debug("VoiceWS: Resetting isConnectingInProgress flag.");
|
|
||||||
isConnectingInProgress = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Cleans up socket event handlers and closes the socket if open/connecting.
|
|
||||||
// This function does NOT change the Zustand state directly, relying on callers or event handlers (like onclose) to do that.
|
|
||||||
const cleanupConnectionInternals = () => {
|
|
||||||
console.debug(`VoiceWS: Cleaning up connection internals.`);
|
|
||||||
if (socket) {
|
|
||||||
socket.onopen = null; socket.onmessage = null; socket.onerror = null; socket.onclose = null;
|
|
||||||
if (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING) {
|
|
||||||
try { socket.close(1000, "Client cleanup"); }
|
|
||||||
catch (e) { console.warn("VoiceWS: Error closing socket during cleanup:", e); }
|
|
||||||
}
|
|
||||||
socket = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOpen = async () => {
|
|
||||||
console.log("VoiceWS: Connection established. Authenticating...");
|
|
||||||
set({ status: "AUTHENTICATING", lastError: null });
|
|
||||||
|
|
||||||
if (!getTokenFunc) {
|
|
||||||
console.error("VoiceWS: Auth failed. Token getter missing.");
|
|
||||||
resetConnectingFlag();
|
|
||||||
set({ status: "ERROR", lastError: "Token provider missing for authentication." });
|
|
||||||
cleanupConnectionInternals();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const token = await getTokenFunc();
|
|
||||||
if (!token) {
|
|
||||||
console.error("VoiceWS: Auth failed. No token from getter.");
|
|
||||||
isIntentionalDisconnect = true; // So handleClose doesn't treat as "unexpected"
|
|
||||||
resetConnectingFlag();
|
|
||||||
set({ status: "ERROR", lastError: "Authentication token not available." });
|
|
||||||
cleanupConnectionInternals();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// If sendWebSocketMessage fails here, it will set state to ERROR and cleanup.
|
|
||||||
if (!sendWebSocketMessage({ type: "AUTHENTICATE", data: { token } })) {
|
|
||||||
// Error state already set by sendWebSocketMessage if it failed critically
|
|
||||||
resetConnectingFlag(); // Ensure flag is reset
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("VoiceWS: Error getting token for auth:", error);
|
|
||||||
isIntentionalDisconnect = true;
|
|
||||||
resetConnectingFlag();
|
|
||||||
set({ status: "ERROR", lastError: "Failed to retrieve authentication token." });
|
|
||||||
cleanupConnectionInternals();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMessage = async (event: MessageEvent) => {
|
|
||||||
try {
|
|
||||||
const message = JSON.parse(event.data as string) as VoiceServerMessage;
|
|
||||||
console.debug("VoiceWS: Received message:", message.type, message.data);
|
|
||||||
|
|
||||||
switch (message.type) {
|
|
||||||
case "AUTHENTICATE_ACCEPTED":
|
|
||||||
resetConnectingFlag();
|
|
||||||
set({ status: "CONNECTED", lastError: null });
|
|
||||||
console.log(`VoiceWS: Authenticated successfully.`);
|
|
||||||
break;
|
|
||||||
case "AUTHENTICATE_DENIED":
|
|
||||||
resetConnectingFlag();
|
|
||||||
const reason = message.data?.reason || "No reason provided";
|
|
||||||
console.warn(`VoiceWS: Authentication denied by server. Reason: ${reason}`);
|
|
||||||
isIntentionalDisconnect = true; // Server is denying, so it's "intentional" from that PoV
|
|
||||||
serverReportedError = false; // This is an auth failure, not a general server runtime error
|
|
||||||
set({ status: "ERROR", lastError: `Authentication denied: ${reason}` });
|
|
||||||
// Server should close the connection. We can prompt it.
|
|
||||||
if (socket) socket.close(1000, "Authentication Denied");
|
|
||||||
// cleanupConnectionInternals() will be called by handleClose
|
|
||||||
break;
|
|
||||||
case "SDP_ANSWER":
|
|
||||||
if (voiceCallbacks.onSdpAnswer) {
|
|
||||||
voiceCallbacks.onSdpAnswer(message.data);
|
|
||||||
} else {
|
|
||||||
console.warn("VoiceWS: Received SDP_ANSWER but no handler is registered.");
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case "ERROR":
|
|
||||||
resetConnectingFlag();
|
|
||||||
const errCode = message.data.code;
|
|
||||||
const errMsg = message.data.message || "Unknown server error";
|
|
||||||
console.error(`VoiceWS: Server reported error. Code: ${errCode}, Message: "${errMsg}"`);
|
|
||||||
serverReportedError = true;
|
|
||||||
isIntentionalDisconnect = true; // Server error implies server wants to stop.
|
|
||||||
set({
|
|
||||||
status: "ERROR",
|
|
||||||
lastError: `Server error (${errCode}): ${errMsg}`,
|
|
||||||
});
|
|
||||||
// Server should close the connection. We can prompt it.
|
|
||||||
if (socket) socket.close(1000, "Server Reported Error");
|
|
||||||
// cleanupConnectionInternals() will be called by handleClose
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
const _exhaustiveCheck: never = message;
|
|
||||||
console.warn("VoiceWS: Received unknown server message type:", _exhaustiveCheck);
|
|
||||||
set({ status: "ERROR", lastError: "Received unknown message type from server." });
|
|
||||||
if (socket) socket.close(1000, "Unknown message type");
|
|
||||||
return _exhaustiveCheck;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("VoiceWS: Failed to parse or handle server message:", error, "Raw data:", event.data);
|
|
||||||
resetConnectingFlag();
|
|
||||||
set({ status: "ERROR", lastError: "Failed to parse server message." });
|
|
||||||
if (socket) socket.close(1000, "Message parsing error");
|
|
||||||
// cleanupConnectionInternals() will be called by handleClose
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleError = (event: Event) => {
|
|
||||||
console.error("VoiceWS: WebSocket error event occurred:", event);
|
|
||||||
// This event often precedes `onclose`.
|
|
||||||
// `isConnectingInProgress` should be reset by `onclose`.
|
|
||||||
// Set lastError if not already set by a more specific server message or a connection setup failure.
|
|
||||||
set(state => ({
|
|
||||||
lastError: state.lastError || `WebSocket error: ${event.type || 'Unknown error'}`
|
|
||||||
}));
|
|
||||||
// Do not change status to ERROR here; onclose will give the definitive closure reason.
|
|
||||||
// However, ensure isConnectingInProgress is reset if this is a terminal error before onclose.
|
|
||||||
if (socket?.readyState === WebSocket.CLOSING || socket?.readyState === WebSocket.CLOSED) {
|
|
||||||
resetConnectingFlag();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClose = (event: CloseEvent) => {
|
|
||||||
const wasInProgress = isConnectingInProgress; // Capture before reset
|
|
||||||
resetConnectingFlag(); // Connection process is definitely over.
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`VoiceWS: Connection closed. Code: ${event.code}, Reason: "${event.reason || 'N/A'}", Clean: ${event.wasClean}, Intentional: ${isIntentionalDisconnect}, ServerError: ${serverReportedError}, WasInProgress: ${wasInProgress}`
|
|
||||||
);
|
|
||||||
|
|
||||||
cleanupConnectionInternals(); // Ensure all handlers are detached and socket is nulled.
|
|
||||||
|
|
||||||
const currentStatus = get().status;
|
|
||||||
const currentError = get().lastError;
|
|
||||||
|
|
||||||
// If status was already set to ERROR by handleMessage (e.g. AUTH_DENIED, server ERROR),
|
|
||||||
// or by a failure during connect/auth phases, preserve that specific error.
|
|
||||||
if (currentStatus === "ERROR") {
|
|
||||||
// If lastError is generic like "Failed to initialize...", update with close event info if more specific.
|
|
||||||
if (currentError === "Failed to initialize WebSocket connection." || !currentError) {
|
|
||||||
set({ lastError: `Connection closed: Code ${event.code}, Reason: "${event.reason || 'N/A'}"` });
|
|
||||||
}
|
|
||||||
// Otherwise, the specific error (like auth denial) is already set and should be kept.
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If an explicit server error message was received and handled
|
|
||||||
if (serverReportedError) {
|
|
||||||
set({ status: "ERROR", lastError: currentError || `Server error led to closure (Code: ${event.code})` });
|
|
||||||
}
|
|
||||||
// If disconnect was called explicitly, or an auth denial occurred (isIntentionalDisconnect=true)
|
|
||||||
else if (isIntentionalDisconnect) {
|
|
||||||
set({
|
|
||||||
status: "DISCONNECTED",
|
|
||||||
lastError: currentError || (event.wasClean ? "Disconnected" : `Disconnected: Code ${event.code}, Reason: "${event.reason || 'N/A'}"`)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// Otherwise, it's an unexpected closure (e.g., network drop, server crash without specific ERROR message)
|
|
||||||
else {
|
|
||||||
set({
|
|
||||||
status: "ERROR",
|
|
||||||
lastError: `Connection lost unexpectedly: Code ${event.code}, Reason: "${event.reason || 'N/A'}"`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const _connect = async () => {
|
|
||||||
const callId = currentConnectCallId;
|
|
||||||
|
|
||||||
// This check is crucial: if a server error occurred, only a new explicit `connect()`
|
|
||||||
// (which resets serverReportedError via the public connect method) should allow a new attempt.
|
|
||||||
if (serverReportedError) {
|
|
||||||
console.warn("VoiceWS: _connect aborted due to prior server-reported error. Call public connect() to retry.");
|
|
||||||
// State should already be ERROR. Ensure connecting flag is false.
|
|
||||||
resetConnectingFlag();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!currentWebSocketUrl) { // Should be set by public connect()
|
|
||||||
console.error("VoiceWS: Cannot connect. WebSocket URL missing.");
|
|
||||||
set({ status: "ERROR", lastError: "WebSocket URL not provided." });
|
|
||||||
resetConnectingFlag();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!getTokenFunc) { // Should be set by public connect()
|
|
||||||
console.error("VoiceWS: Cannot connect. Token getter missing.");
|
|
||||||
set({ status: "ERROR", lastError: "Token provider missing." });
|
|
||||||
resetConnectingFlag();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`VoiceWS (_connect [${callId}]): Attempting to connect to ${currentWebSocketUrl}...`);
|
|
||||||
isConnectingInProgress = true; // Set flag: connection attempt now in progress.
|
|
||||||
|
|
||||||
try {
|
|
||||||
const token = await getTokenFunc(); // Pre-flight check for token
|
|
||||||
if (!token) {
|
|
||||||
console.warn("VoiceWS: No token available during connection attempt. Aborting.");
|
|
||||||
set({ status: "ERROR", lastError: "Authentication token unavailable for connection." });
|
|
||||||
isIntentionalDisconnect = true; // To guide handleClose if something unexpected happens next
|
|
||||||
resetConnectingFlag();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("VoiceWS: Error getting token before connecting:", error);
|
|
||||||
set({ status: "ERROR", lastError: "Failed to retrieve authentication token before connecting." });
|
|
||||||
isIntentionalDisconnect = true;
|
|
||||||
resetConnectingFlag();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
set({ status: "CONNECTING", lastError: null }); // Fresh connection attempt
|
|
||||||
|
|
||||||
try {
|
|
||||||
socket = new WebSocket(currentWebSocketUrl);
|
|
||||||
socket.onopen = handleOpen;
|
|
||||||
socket.onmessage = handleMessage;
|
|
||||||
socket.onerror = handleError;
|
|
||||||
socket.onclose = handleClose;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`VoiceWS: Failed to create WebSocket instance for ${currentWebSocketUrl}:`, error);
|
|
||||||
set({ status: "ERROR", lastError: "Failed to initialize WebSocket connection." });
|
|
||||||
socket = null;
|
|
||||||
resetConnectingFlag(); // Reset before cleanup if WebSocket creation itself failed
|
|
||||||
// cleanupConnectionInternals(); // Not strictly needed as socket is null, but harmless.
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: "IDLE",
|
|
||||||
lastError: null,
|
|
||||||
|
|
||||||
connect: (newWebSocketUrl, newGetTokenFunc) => {
|
|
||||||
const localCallId = ++currentConnectCallId;
|
|
||||||
const currentStatus = get().status;
|
|
||||||
console.log(`VoiceWS: Explicit connect requested (Call ID: ${localCallId}). URL: ${newWebSocketUrl}. Current status: ${currentStatus}, InProgress: ${isConnectingInProgress}`);
|
|
||||||
|
|
||||||
if (currentStatus === "CONNECTED" && socket?.readyState === WebSocket.OPEN && currentWebSocketUrl === newWebSocketUrl) {
|
|
||||||
console.warn(`VoiceWS (connect [${localCallId}]): Already connected to the same URL.`);
|
|
||||||
resetConnectingFlag(); // Ensure it's false if truly connected.
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prevent concurrent explicit `connect` calls if one is already in `CONNECTING` or `AUTHENTICATING`
|
|
||||||
if (isConnectingInProgress && (currentStatus === "CONNECTING" || currentStatus === "AUTHENTICATING")) {
|
|
||||||
console.warn(`VoiceWS (connect [${localCallId}]): Connect called while a connection attempt (${currentStatus}) is already in progress. Aborting new call.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset flags for a *new* explicit connection sequence
|
|
||||||
isIntentionalDisconnect = false;
|
|
||||||
serverReportedError = false;
|
|
||||||
// isConnectingInProgress will be set by _connect if it proceeds.
|
|
||||||
// Ensure it's reset if we are cleaning up an old connection attempt first.
|
|
||||||
resetConnectingFlag();
|
|
||||||
|
|
||||||
|
|
||||||
if (socket) { // If there's an old socket (e.g. from a failed/interrupted attempt)
|
|
||||||
console.warn(`VoiceWS (connect [${localCallId}]): Explicit connect called with existing socket (state: ${socket.readyState}). Cleaning up old one.`);
|
|
||||||
cleanupConnectionInternals(); // This will close the old socket and nullify it.
|
|
||||||
}
|
|
||||||
|
|
||||||
currentWebSocketUrl = newWebSocketUrl;
|
|
||||||
getTokenFunc = newGetTokenFunc;
|
|
||||||
|
|
||||||
_connect(); // Start the connection process
|
|
||||||
},
|
|
||||||
|
|
||||||
disconnect: (intentional = true) => {
|
|
||||||
const callId = currentConnectCallId;
|
|
||||||
console.log(`VoiceWS (disconnect [${callId}]): Disconnect requested. Intentional: ${intentional}, InProgress: ${isConnectingInProgress}`);
|
|
||||||
|
|
||||||
const previousStatus = get().status;
|
|
||||||
const previousError = get().lastError;
|
|
||||||
|
|
||||||
isIntentionalDisconnect = intentional;
|
|
||||||
if (intentional) {
|
|
||||||
// User action clears server error flag, allowing a future manual connect to proceed
|
|
||||||
// without being blocked by a previous serverReportedError.
|
|
||||||
serverReportedError = false;
|
|
||||||
}
|
|
||||||
resetConnectingFlag(); // Stop any perceived ongoing connection attempt.
|
|
||||||
|
|
||||||
cleanupConnectionInternals(); // This will close the socket if open, triggering onclose.
|
|
||||||
|
|
||||||
// Set final state. `handleClose` will also run and might refine `lastError`
|
|
||||||
// based on close event details if it was an unexpected close.
|
|
||||||
if (previousStatus === "ERROR" && !intentional) {
|
|
||||||
// If it was already an error and this disconnect is not user-initiated (e.g. internal call)
|
|
||||||
set({ status: "ERROR", lastError: previousError || "Disconnected due to an error." });
|
|
||||||
} else {
|
|
||||||
set({
|
|
||||||
status: "DISCONNECTED",
|
|
||||||
lastError: intentional ? "User disconnected" : (previousError || "Disconnected"),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
sendSdpOffer: (sdp) => {
|
|
||||||
if (get().status !== "CONNECTED") {
|
|
||||||
console.warn("VoiceWS: Cannot send SDP_OFFER. Not connected.");
|
|
||||||
// Optionally set lastError if this is considered an application error
|
|
||||||
// set(state => ({ lastError: state.lastError || "Attempted to send offer while not connected." }));
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return sendWebSocketMessage({ type: "SDP_OFFER", data: { sdp } });
|
|
||||||
},
|
|
||||||
|
|
||||||
setCallbacks: (newCallbacks) => {
|
|
||||||
voiceCallbacks = { ...voiceCallbacks, ...newCallbacks };
|
|
||||||
console.debug("VoiceWS: Callbacks updated.", voiceCallbacks);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Optional: Online/Offline listeners
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
const handleBrowserOnline = () => {
|
|
||||||
const { status } = useVoiceWebSocketStore.getState();
|
|
||||||
console.log("VoiceWS: Browser online event detected.");
|
|
||||||
|
|
||||||
if (['DISCONNECTED', 'ERROR'].includes(status) && !isConnectingInProgress) {
|
|
||||||
console.log("VoiceWS: Browser is online. Application may re-attempt connection if desired by calling connect().");
|
|
||||||
// Example: Notify the application if it wants to handle this
|
|
||||||
// if (voiceCallbacks.onBrowserOnline) {
|
|
||||||
// voiceCallbacks.onBrowserOnline();
|
|
||||||
// }
|
|
||||||
// Or dispatch a global event:
|
|
||||||
// window.dispatchEvent(new CustomEvent('voiceWsBrowserOnline'));
|
|
||||||
} else {
|
|
||||||
console.log(`VoiceWS: Browser online, no action needed. Status: ${status}, ServerError: ${serverReportedError}, ConnectingInProgress: ${isConnectingInProgress}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
window.addEventListener('online', handleBrowserOnline);
|
|
||||||
|
|
||||||
const handleAppClose = () => { // Best-effort for page unload
|
|
||||||
const { disconnect, status } = useVoiceWebSocketStore.getState();
|
|
||||||
if (status !== "IDLE" && status !== "DISCONNECTED") {
|
|
||||||
console.log("VoiceWS: Disconnecting due to page unload.");
|
|
||||||
disconnect(true); // Intentional disconnect
|
|
||||||
}
|
|
||||||
};
|
|
||||||
window.addEventListener('beforeunload', handleAppClose);
|
|
||||||
}
|
|
||||||
@@ -1,332 +0,0 @@
|
|||||||
import { create } from 'zustand';
|
|
||||||
import type { Uuid } from '~/lib/api/types';
|
|
||||||
import type { VoiceServerUpdateServerEvent } from '~/lib/websocket/gateway.types'; // Adjust path
|
|
||||||
import type { SdpAnswerVoicePayload } from '~/lib/websocket/voice.types';
|
|
||||||
import { useGatewayWebSocketStore } from './gateway-websocket'; // Adjust path
|
|
||||||
import { useVoiceWebSocketStore } from './voice-websocket'; // Adjust path
|
|
||||||
|
|
||||||
export type WebRTCStatus =
|
|
||||||
| "IDLE"
|
|
||||||
| "REQUESTING_VOICE_SERVER" // Waiting for Gateway WS to provide voice server info
|
|
||||||
| "CONNECTING_VOICE_WS" // Voice WS connection in progress
|
|
||||||
| "NEGOTIATING_SDP" // SDP Offer/Answer exchange in progress
|
|
||||||
| "ICE_GATHERING" // ICE candidates are being gathered
|
|
||||||
| "ICE_CONNECTING" // ICE connection in progress
|
|
||||||
| "CONNECTED" // WebRTC connection established, media flowing
|
|
||||||
| "DISCONNECTED"
|
|
||||||
| "FAILED";
|
|
||||||
|
|
||||||
interface WebRTCState {
|
|
||||||
status: WebRTCStatus;
|
|
||||||
peerConnection: RTCPeerConnection | null;
|
|
||||||
localStream: MediaStream | null;
|
|
||||||
remoteStream: MediaStream | null;
|
|
||||||
lastError: string | null;
|
|
||||||
currentChannelId: Uuid | null; // To track which channel we are in
|
|
||||||
_internalUnsubscribeVoiceWs: ReturnType<typeof useVoiceWebSocketStore.subscribe> | undefined;
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
joinVoiceChannel: (serverId: Uuid, channelId: Uuid, localStream: MediaStream) => Promise<void>;
|
|
||||||
leaveVoiceChannel: () => void;
|
|
||||||
_handleVoiceConnectionInfo: (info: VoiceServerUpdateServerEvent) => void; // Internal, called by GatewayWS
|
|
||||||
|
|
||||||
// For ICE candidate handling (if your server supports trickle ICE via Voice WS)
|
|
||||||
// sendIceCandidate: (candidate: RTCIceCandidateInit) => void;
|
|
||||||
// _handleIceCandidate: (candidate: RTCIceCandidateInit) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default ICE server configuration (replace with your own if needed)
|
|
||||||
const defaultIceServers: RTCIceServer[] = [];
|
|
||||||
|
|
||||||
let currentVoiceWsUrl: string | null = null;
|
|
||||||
let currentVoiceToken: string | null = null;
|
|
||||||
|
|
||||||
export const useWebRTCStore = create<WebRTCState>((set, get) => ({
|
|
||||||
status: "IDLE",
|
|
||||||
peerConnection: null,
|
|
||||||
localStream: null,
|
|
||||||
remoteStream: null,
|
|
||||||
lastError: null,
|
|
||||||
currentChannelId: null,
|
|
||||||
_internalUnsubscribeVoiceWs: undefined,
|
|
||||||
|
|
||||||
joinVoiceChannel: async (serverId, channelId, localStream) => {
|
|
||||||
const { status: gatewayStatus, sendVoiceStateUpdate } = useGatewayWebSocketStore.getState();
|
|
||||||
const currentWebRTCStatus = get().status;
|
|
||||||
|
|
||||||
if (currentWebRTCStatus !== "IDLE" && currentWebRTCStatus !== "DISCONNECTED" && currentWebRTCStatus !== "FAILED") {
|
|
||||||
console.warn(`WebRTC: Cannot join channel. Current status: ${currentWebRTCStatus}`);
|
|
||||||
set({ lastError: "WebRTC: Join attempt while already active or in progress." });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (gatewayStatus !== "CONNECTED") {
|
|
||||||
console.error("WebRTC: Gateway WebSocket not connected. Cannot send VOICE_STATE_UPDATE.");
|
|
||||||
set({ status: "FAILED", lastError: "Gateway WebSocket not connected." });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
set({
|
|
||||||
status: "REQUESTING_VOICE_SERVER",
|
|
||||||
localStream, // Store the local stream
|
|
||||||
lastError: null,
|
|
||||||
currentChannelId: channelId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const payload = { serverId, channelId };
|
|
||||||
if (!sendVoiceStateUpdate(payload)) {
|
|
||||||
console.error("WebRTC: Failed to send VOICE_STATE_UPDATE via Gateway WS.");
|
|
||||||
set({ status: "FAILED", lastError: "Failed to send voice state update." });
|
|
||||||
// Revert localStream if needed, or leaveVoiceChannel will clean it up
|
|
||||||
} else {
|
|
||||||
console.log("WebRTC: VOICE_STATE_UPDATE sent. Waiting for VOICE_CONNECTION_INFO...");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
_handleVoiceConnectionInfo: (info: VoiceServerUpdateServerEvent) => {
|
|
||||||
if (get().status !== "REQUESTING_VOICE_SERVER") {
|
|
||||||
console.warn("WebRTC: Received VOICE_CONNECTION_INFO in unexpected state:", get().status);
|
|
||||||
return; // Or handle error
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("WebRTC: Received voice connection info. Initializing PeerConnection and Voice WS.", info);
|
|
||||||
// currentVoiceWsUrl = info.voiceServerUrl;
|
|
||||||
currentVoiceWsUrl = "ws://localhost:12345/voice/ws";
|
|
||||||
currentVoiceToken = info.data.token;
|
|
||||||
|
|
||||||
const pc = new RTCPeerConnection({ iceServers: defaultIceServers });
|
|
||||||
|
|
||||||
// pc.onicecandidate = (event) => {
|
|
||||||
// if (event.candidate) {
|
|
||||||
// console.log("WebRTC: New ICE candidate generated:", event.candidate);
|
|
||||||
// // IMPORTANT: You need a way to send this to the server via Voice WS
|
|
||||||
// // Example: get().sendIceCandidate(event.candidate.toJSON());
|
|
||||||
// // This requires `sendIceCandidate` on useVoiceWebSocketStore and server support
|
|
||||||
// useVoiceWebSocketStore.getState().sendIceCandidate(event.candidate.toJSON()); // Assuming this exists
|
|
||||||
// } else {
|
|
||||||
// console.log("WebRTC: All ICE candidates have been gathered.");
|
|
||||||
// }
|
|
||||||
// };
|
|
||||||
|
|
||||||
pc.oniceconnectionstatechange = () => {
|
|
||||||
console.log("WebRTC: ICE connection state change:", pc.iceConnectionState);
|
|
||||||
switch (pc.iceConnectionState) {
|
|
||||||
case "connected":
|
|
||||||
case "completed":
|
|
||||||
set({ status: "CONNECTED", lastError: null });
|
|
||||||
break;
|
|
||||||
case "disconnected":
|
|
||||||
// Can sometimes recover, or might lead to "failed"
|
|
||||||
// For now, we might treat as a more final state or wait for "failed"
|
|
||||||
set({ status: "DISCONNECTED", lastError: "ICE connection disconnected." });
|
|
||||||
// Consider calling leaveVoiceChannel or attempting reconnection based on your strategy
|
|
||||||
get().leaveVoiceChannel(); // Simple cleanup on disconnect
|
|
||||||
break;
|
|
||||||
case "failed":
|
|
||||||
set({ status: "FAILED", lastError: "ICE connection failed." });
|
|
||||||
get().leaveVoiceChannel(); // Cleanup
|
|
||||||
break;
|
|
||||||
case "closed":
|
|
||||||
set(state => (state.status !== "IDLE" ? { status: "DISCONNECTED", lastError: state.lastError || "ICE connection closed." } : {}));
|
|
||||||
break;
|
|
||||||
case "new":
|
|
||||||
case "checking":
|
|
||||||
set(state => (state.status === "NEGOTIATING_SDP" || state.status === "ICE_GATHERING" ? { status: "ICE_CONNECTING" } : {}));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
pc.ontrack = (event) => {
|
|
||||||
console.log("WebRTC: Received remote track:", event.track, event.streams);
|
|
||||||
if (event.streams && event.streams[0]) {
|
|
||||||
set({ remoteStream: event.streams[0] });
|
|
||||||
} else {
|
|
||||||
// Fallback for older browsers if streams[0] is not available
|
|
||||||
const newStream = new MediaStream();
|
|
||||||
newStream.addTrack(event.track);
|
|
||||||
set({ remoteStream: newStream });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add local tracks to the peer connection
|
|
||||||
const localStream = get().localStream;
|
|
||||||
if (localStream) {
|
|
||||||
localStream.getTracks().forEach(track => {
|
|
||||||
try {
|
|
||||||
pc.addTrack(track, localStream);
|
|
||||||
console.log("WebRTC: Added local track:", track.kind);
|
|
||||||
} catch (e) {
|
|
||||||
console.error("WebRTC: Error adding local track:", e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
console.warn("WebRTC: No local stream available to add tracks from.");
|
|
||||||
// You might want to create a default offer without tracks or handle this case
|
|
||||||
// For voice, usually you expect a local audio track.
|
|
||||||
}
|
|
||||||
|
|
||||||
set({ peerConnection: pc, status: "CONNECTING_VOICE_WS" });
|
|
||||||
|
|
||||||
// Connect to Voice WebSocket
|
|
||||||
const { connect: connectVoiceWs, setCallbacks: setVoiceCallbacks, status: voiceWsStatus } = useVoiceWebSocketStore.getState();
|
|
||||||
|
|
||||||
// Define a handler for SDP Answer from Voice WS
|
|
||||||
const handleSdpAnswer = (payload: SdpAnswerVoicePayload) => {
|
|
||||||
const currentPc = get().peerConnection;
|
|
||||||
if (!currentPc || get().status !== "NEGOTIATING_SDP") {
|
|
||||||
console.warn("WebRTC: Received SDP Answer in unexpected state or no PC.", get().status);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
console.log("WebRTC: Received SDP Answer. Setting remote description.");
|
|
||||||
currentPc.setRemoteDescription(new RTCSessionDescription(payload.sdp))
|
|
||||||
.then(() => {
|
|
||||||
console.log("WebRTC: Remote description set successfully.");
|
|
||||||
// ICE gathering might already be in progress or starting now.
|
|
||||||
// The oniceconnectionstatechange will handle moving to CONNECTED.
|
|
||||||
set({ status: "ICE_GATHERING" }); // Or directly to ICE_CONNECTING if candidates start flowing
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
console.error("WebRTC: Failed to set remote description:", err);
|
|
||||||
set({ status: "FAILED", lastError: "Failed to set remote SDP answer." });
|
|
||||||
get().leaveVoiceChannel();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Define a handler for ICE Candidates from Voice WS (if server sends them)
|
|
||||||
const handleVoiceWsIceCandidate = (candidate: RTCIceCandidateInit) => {
|
|
||||||
const currentPc = get().peerConnection;
|
|
||||||
if (!currentPc) {
|
|
||||||
console.warn("WebRTC: Received ICE candidate but no peer connection.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
console.log("WebRTC: Received ICE candidate from VoiceWS, adding to PC:", candidate);
|
|
||||||
currentPc.addIceCandidate(new RTCIceCandidate(candidate)).catch(e => {
|
|
||||||
console.error("WebRTC: Error adding received ICE candidate:", e);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
// Set callbacks for the Voice WS store
|
|
||||||
setVoiceCallbacks({
|
|
||||||
onSdpAnswer: handleSdpAnswer,
|
|
||||||
// onIceCandidate: handleVoiceWsIceCandidate, // Assuming Voice WS supports this
|
|
||||||
});
|
|
||||||
|
|
||||||
// Function to create and send offer
|
|
||||||
const createAndSendOffer = async () => {
|
|
||||||
const currentPc = get().peerConnection;
|
|
||||||
if (!currentPc || get().status !== "NEGOTIATING_SDP") { // Check should be before setting to NEGOTIATING_SDP
|
|
||||||
console.warn("WebRTC: Cannot create offer, PC not ready or wrong state.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
console.log("WebRTC: Creating SDP Offer...");
|
|
||||||
try {
|
|
||||||
const offer = await currentPc.createOffer({
|
|
||||||
// Offer to receive audio/video based on what you expect
|
|
||||||
// For voice only:
|
|
||||||
offerToReceiveAudio: true,
|
|
||||||
offerToReceiveVideo: false, // Set to true if you expect video
|
|
||||||
});
|
|
||||||
await currentPc.setLocalDescription(offer);
|
|
||||||
console.log("WebRTC: Local description set. Sending offer via Voice WS.");
|
|
||||||
useVoiceWebSocketStore.getState().sendSdpOffer(offer as RTCSessionDescriptionInit); // Cast needed as createOffer returns RTCSessionDescriptionInit
|
|
||||||
} catch (err) {
|
|
||||||
console.error("WebRTC: Failed to create or send SDP offer:", err);
|
|
||||||
set({ status: "FAILED", lastError: "Failed to create/send SDP offer." });
|
|
||||||
get().leaveVoiceChannel();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
// Subscribe to Voice WS status changes
|
|
||||||
// We need to wait for Voice WS to be 'CONNECTED' before sending offer
|
|
||||||
const unsubscribeVoiceWs = useVoiceWebSocketStore.subscribe(
|
|
||||||
(newVoiceStatus, oldVoiceStatus) => {
|
|
||||||
if (newVoiceStatus.status === "CONNECTED" && get().status === "CONNECTING_VOICE_WS") {
|
|
||||||
console.log("WebRTC: Voice WS connected. Proceeding to SDP negotiation.");
|
|
||||||
set({ status: "NEGOTIATING_SDP" });
|
|
||||||
createAndSendOffer(); // Now create and send offer
|
|
||||||
} else if (newVoiceStatus.status === "ERROR" || newVoiceStatus.status === "DISCONNECTED") {
|
|
||||||
if (get().status === "CONNECTING_VOICE_WS" || get().status === "NEGOTIATING_SDP" || get().status === "ICE_GATHERING" || get().status === "ICE_CONNECTING") {
|
|
||||||
console.error("WebRTC: Voice WS disconnected or errored during WebRTC setup.", useVoiceWebSocketStore.getState().lastError);
|
|
||||||
set({ status: "FAILED", lastError: `Voice WebSocket error: ${useVoiceWebSocketStore.getState().lastError || 'Disconnected'}` });
|
|
||||||
get().leaveVoiceChannel(); // Cleanup WebRTC part
|
|
||||||
unsubscribeVoiceWs(); // Clean up subscription
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
// Store unsubscribe function for cleanup in leaveVoiceChannel
|
|
||||||
set(state => ({ ...state, _internalUnsubscribeVoiceWs: unsubscribeVoiceWs }));
|
|
||||||
|
|
||||||
|
|
||||||
// Initiate Voice WS connection
|
|
||||||
if (currentVoiceWsUrl && currentVoiceToken) {
|
|
||||||
console.log(`WebRTC: Connecting to Voice WS: ${currentVoiceWsUrl}`);
|
|
||||||
connectVoiceWs(currentVoiceWsUrl, () => Promise.resolve(currentVoiceToken)); // Token is already a string
|
|
||||||
} else {
|
|
||||||
console.error("WebRTC: Voice WS URL or Token missing.");
|
|
||||||
set({ status: "FAILED", lastError: "Voice WS URL or Token missing." });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
leaveVoiceChannel: () => {
|
|
||||||
console.log("WebRTC: Leaving voice channel.");
|
|
||||||
const { peerConnection, localStream, _internalUnsubscribeVoiceWs } = get();
|
|
||||||
|
|
||||||
if (_internalUnsubscribeVoiceWs) {
|
|
||||||
_internalUnsubscribeVoiceWs(); // Unsubscribe from Voice WS status
|
|
||||||
}
|
|
||||||
|
|
||||||
if (peerConnection) {
|
|
||||||
peerConnection.getSenders().forEach(sender => {
|
|
||||||
if (sender.track) {
|
|
||||||
sender.track.stop();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
peerConnection.getReceivers().forEach(receiver => {
|
|
||||||
if (receiver.track) {
|
|
||||||
receiver.track.stop();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
peerConnection.close();
|
|
||||||
}
|
|
||||||
if (localStream) {
|
|
||||||
localStream.getTracks().forEach(track => track.stop());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Disconnect Voice WebSocket
|
|
||||||
const { status: voiceStatus, disconnect: disconnectVoiceWs } = useVoiceWebSocketStore.getState();
|
|
||||||
if (voiceStatus !== "IDLE" && voiceStatus !== "DISCONNECTED") {
|
|
||||||
disconnectVoiceWs(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Optionally, inform Gateway server if needed
|
|
||||||
// useGatewayWebSocketStore.getState().sendVoiceStateUpdate({ channelId: null, serverId: get().currentServerId }); // Example
|
|
||||||
|
|
||||||
set({
|
|
||||||
status: "IDLE", // Or "DISCONNECTED" if you prefer that as the terminal state after a session
|
|
||||||
peerConnection: null,
|
|
||||||
localStream: null,
|
|
||||||
remoteStream: null,
|
|
||||||
lastError: null,
|
|
||||||
currentChannelId: null,
|
|
||||||
_internalUnsubscribeVoiceWs: undefined,
|
|
||||||
});
|
|
||||||
currentVoiceWsUrl = null;
|
|
||||||
currentVoiceToken = null;
|
|
||||||
},
|
|
||||||
|
|
||||||
// Placeholder for sending ICE Candidate via Voice WebSocket
|
|
||||||
// sendIceCandidate: (candidate: RTCIceCandidateInit) => {
|
|
||||||
// useVoiceWebSocketStore.getState().sendIceCandidate(candidate); // You'll need to implement sendIceCandidate in useVoiceWebSocketStore
|
|
||||||
// },
|
|
||||||
// Placeholder for handling ICE Candidate from Voice WebSocket
|
|
||||||
// _handleIceCandidate: (candidate: RTCIceCandidateInit) => {
|
|
||||||
// const pc = get().peerConnection;
|
|
||||||
// if (pc && candidate) {
|
|
||||||
// pc.addIceCandidate(new RTCIceCandidate(candidate)).catch(e => {
|
|
||||||
// console.error("WebRTC: Error adding received ICE candidate:", e);
|
|
||||||
// });
|
|
||||||
// }
|
|
||||||
// },
|
|
||||||
}));
|
|
||||||
44
app/stores/channels-voice-state.tsx
Normal file
44
app/stores/channels-voice-state.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import { immer } from "zustand/middleware/immer";
|
||||||
|
import type { ChannelId, UserId } from "~/lib/api/types";
|
||||||
|
|
||||||
|
interface UserVoiceState {
|
||||||
|
deaf: boolean;
|
||||||
|
muted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChannelVoiceState {
|
||||||
|
users: Record<UserId, UserVoiceState>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChannelsVoiceState {
|
||||||
|
channels: Record<ChannelId, ChannelVoiceState>;
|
||||||
|
addUser: (channelId: ChannelId, userId: UserId, userVoiceState: UserVoiceState) => void;
|
||||||
|
removeUser: (channelId: ChannelId, userId: UserId) => void;
|
||||||
|
removeChannel: (channelId: ChannelId) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useChannelsVoiceStateStore = create<ChannelsVoiceState>()(
|
||||||
|
immer(
|
||||||
|
(set, get) => ({
|
||||||
|
channels: {},
|
||||||
|
addUser: (channelId, userId, userVoiceState) => set((state) => {
|
||||||
|
if (!state.channels[channelId]) {
|
||||||
|
state.channels[channelId] = {
|
||||||
|
users: {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
state.channels[channelId].users[userId] = userVoiceState;
|
||||||
|
}),
|
||||||
|
removeUser: (channelId, userId) => set((state) => {
|
||||||
|
if (state.channels[channelId]) {
|
||||||
|
delete state.channels[channelId].users[userId];
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
removeChannel: (channelId) => set((state) => {
|
||||||
|
delete state.channels[channelId];
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
171
app/stores/gateway-store.ts
Normal file
171
app/stores/gateway-store.ts
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
import type { QueryClient } from '@tanstack/react-query';
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { messageSchema, type ChannelId, type Message, type MessageId, type ServerId } from '~/lib/api/types';
|
||||||
|
import { GatewayClient } from '~/lib/websocket/gateway/client';
|
||||||
|
import {
|
||||||
|
ConnectionState,
|
||||||
|
EventType,
|
||||||
|
type EventData,
|
||||||
|
type VoiceServerUpdateEvent
|
||||||
|
} from '~/lib/websocket/gateway/types';
|
||||||
|
import { useChannelsVoiceStateStore } from './channels-voice-state';
|
||||||
|
import { usePrivateChannelsStore } from './private-channels-store';
|
||||||
|
import { useServerChannelsStore } from './server-channels-store';
|
||||||
|
import { useServerListStore } from './server-list-store';
|
||||||
|
import { useUsersStore } from './users-store';
|
||||||
|
|
||||||
|
const GATEWAY_URL = 'ws://localhost:12345/gateway/ws';
|
||||||
|
|
||||||
|
const HANDLERS = {
|
||||||
|
[EventType.ADD_SERVER]: (self: GatewayState, data: Extract<EventData, { type: EventType.ADD_SERVER }>['data']) => {
|
||||||
|
useServerListStore.getState().addServer(data.server);
|
||||||
|
},
|
||||||
|
|
||||||
|
[EventType.REMOVE_SERVER]: (self: GatewayState, data: Extract<EventData, { type: EventType.REMOVE_SERVER }>['data']) => {
|
||||||
|
useServerListStore.getState().removeServer(data.serverId);
|
||||||
|
useServerChannelsStore.getState().removeServer(data.serverId);
|
||||||
|
useChannelsVoiceStateStore.getState().removeChannel(data.serverId);
|
||||||
|
},
|
||||||
|
|
||||||
|
[EventType.ADD_DM_CHANNEL]: (self: GatewayState, data: Extract<EventData, { type: EventType.ADD_DM_CHANNEL }>['data']) => {
|
||||||
|
usePrivateChannelsStore.getState().addChannel(data.channel);
|
||||||
|
},
|
||||||
|
|
||||||
|
[EventType.REMOVE_DM_CHANNEL]: (self: GatewayState, data: Extract<EventData, { type: EventType.REMOVE_DM_CHANNEL }>['data']) => {
|
||||||
|
usePrivateChannelsStore.getState().removeChannel(data.channelId);
|
||||||
|
useChannelsVoiceStateStore.getState().removeChannel(data.channelId);
|
||||||
|
},
|
||||||
|
|
||||||
|
[EventType.ADD_SERVER_CHANNEL]: (self: GatewayState, data: Extract<EventData, { type: EventType.ADD_SERVER_CHANNEL }>['data']) => {
|
||||||
|
useServerChannelsStore.getState().addChannel(data.channel);
|
||||||
|
},
|
||||||
|
|
||||||
|
[EventType.REMOVE_SERVER_CHANNEL]: (self: GatewayState, data: Extract<EventData, { type: EventType.REMOVE_SERVER_CHANNEL }>['data']) => {
|
||||||
|
useServerChannelsStore.getState().removeChannel(data.serverId, data.channelId);
|
||||||
|
useChannelsVoiceStateStore.getState().removeChannel(data.serverId);
|
||||||
|
},
|
||||||
|
|
||||||
|
[EventType.ADD_USER]: (self: GatewayState, data: Extract<EventData, { type: EventType.ADD_USER }>['data']) => {
|
||||||
|
useUsersStore.getState().addUser(data.user);
|
||||||
|
},
|
||||||
|
|
||||||
|
[EventType.REMOVE_USER]: (self: GatewayState, data: Extract<EventData, { type: EventType.REMOVE_USER }>['data']) => {
|
||||||
|
useUsersStore.getState().removeUser(data.userId);
|
||||||
|
},
|
||||||
|
|
||||||
|
[EventType.ADD_SERVER_MEMBER]: (self: GatewayState, data: Extract<EventData, { type: EventType.ADD_SERVER_MEMBER }>['data']) => {
|
||||||
|
useUsersStore.getState().addUser(data.user);
|
||||||
|
},
|
||||||
|
|
||||||
|
[EventType.REMOVE_SERVER_MEMBER]: (self: GatewayState, data: Extract<EventData, { type: EventType.REMOVE_SERVER_MEMBER }>['data']) => {
|
||||||
|
useUsersStore.getState().removeUser(data.userId);
|
||||||
|
},
|
||||||
|
|
||||||
|
[EventType.ADD_MESSAGE]: (self: GatewayState, data: Extract<EventData, { type: EventType.ADD_MESSAGE }>['data']) => {
|
||||||
|
const message = messageSchema.parse(data.message)
|
||||||
|
|
||||||
|
if (self.queryClient) {
|
||||||
|
self.queryClient.setQueryData(['messages', message.channelId], (oldData: {
|
||||||
|
pages: Message[][],
|
||||||
|
pageParams: MessageId[]
|
||||||
|
}) => {
|
||||||
|
return {
|
||||||
|
pages: oldData?.pages ? [[message, ...oldData.pages[0]], ...oldData.pages.slice(1)] : [[message]],
|
||||||
|
pageParams: oldData?.pageParams ?? [undefined, message.id]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
[EventType.REMOVE_MESSAGE]: (self: GatewayState, data: Extract<EventData, { type: EventType.REMOVE_MESSAGE }>['data']) => {
|
||||||
|
if (self.queryClient) {
|
||||||
|
self.queryClient.setQueryData(['messages', data.channelId], (oldData: any) => {
|
||||||
|
if (!oldData) return [];
|
||||||
|
return oldData.filter((message: any) => message.id !== data.messageId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
[EventType.VOICE_CHANNEL_CONNECTED]: (self: GatewayState, data: Extract<EventData, { type: EventType.VOICE_CHANNEL_CONNECTED }>['data']) => {
|
||||||
|
useChannelsVoiceStateStore.getState().addUser(data.channelId, data.userId, {
|
||||||
|
deaf: false,
|
||||||
|
muted: false
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
[EventType.VOICE_CHANNEL_DISCONNECTED]: (self: GatewayState, data: Extract<EventData, { type: EventType.VOICE_CHANNEL_DISCONNECTED }>['data']) => {
|
||||||
|
useChannelsVoiceStateStore.getState().removeUser(data.channelId, data.userId);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GatewayState {
|
||||||
|
client: GatewayClient | null;
|
||||||
|
queryClient: QueryClient | null;
|
||||||
|
status: ConnectionState;
|
||||||
|
|
||||||
|
connect: (token: string) => void;
|
||||||
|
disconnect: () => void;
|
||||||
|
|
||||||
|
setQueryClient: (client: QueryClient) => void;
|
||||||
|
|
||||||
|
updateVoiceState: (serverId: ServerId, channelId: ChannelId) => void;
|
||||||
|
requestVoiceStates: (serverId: ServerId) => void;
|
||||||
|
onVoiceServerUpdate: (handler: (event: VoiceServerUpdateEvent['data']) => void | Promise<void>) => (() => void);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useGatewayStore = create<GatewayState>()((set, get) => {
|
||||||
|
const client = new GatewayClient(GATEWAY_URL);
|
||||||
|
|
||||||
|
const voiceHandlers = new Set<(event: VoiceServerUpdateEvent['data']) => void>();
|
||||||
|
|
||||||
|
client.onEvent(EventType.VOICE_SERVER_UPDATE, (event) => {
|
||||||
|
voiceHandlers.forEach(handler => handler(event));
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const [type, handler] of Object.entries(HANDLERS)) {
|
||||||
|
client.onEvent(type, (data: any) => {
|
||||||
|
handler(get(), data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
client,
|
||||||
|
queryClient: null,
|
||||||
|
status: ConnectionState.DISCONNECTED,
|
||||||
|
|
||||||
|
connect: (token) => {
|
||||||
|
client.connect(token);
|
||||||
|
set({ status: client.connectionState });
|
||||||
|
|
||||||
|
client.onControl('stateChange', (state) => {
|
||||||
|
set({ status: state });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
disconnect: () => {
|
||||||
|
client.disconnect();
|
||||||
|
set({ status: ConnectionState.DISCONNECTED });
|
||||||
|
},
|
||||||
|
|
||||||
|
setQueryClient: (queryClient) => {
|
||||||
|
set({ queryClient });
|
||||||
|
},
|
||||||
|
|
||||||
|
updateVoiceState: (serverId, channelId) => {
|
||||||
|
client.updateVoiceState(serverId, channelId);
|
||||||
|
},
|
||||||
|
|
||||||
|
requestVoiceStates: (serverId) => {
|
||||||
|
client.requestVoiceStates(serverId);
|
||||||
|
},
|
||||||
|
|
||||||
|
onVoiceServerUpdate: (handler) => {
|
||||||
|
voiceHandlers.add(handler);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
console.log("removing voice server update handler", handler);
|
||||||
|
voiceHandlers.delete(handler);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
49
app/stores/modal-store.ts
Normal file
49
app/stores/modal-store.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import type { ServerId } from "~/lib/api/types";
|
||||||
|
|
||||||
|
export enum ModalType {
|
||||||
|
CREATE_SERVER = "CREATE_SERVER",
|
||||||
|
CREATE_SERVER_CHANNEL = "CREATE_CHANNEL",
|
||||||
|
CREATE_SERVER_INVITE = "CREATE_SERVER_INVITE",
|
||||||
|
DELETE_SERVER_CONFIRM = "DELETE_SERVER_CONFIRM",
|
||||||
|
UPDATE_PROFILE = "UPDATE_PROFILE",
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CreateServerInviteModalData = {
|
||||||
|
type: ModalType.CREATE_SERVER_INVITE;
|
||||||
|
data: {
|
||||||
|
serverId: ServerId;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DeleteServerConfirmModalData = {
|
||||||
|
type: ModalType.CREATE_SERVER_CHANNEL;
|
||||||
|
data: {
|
||||||
|
serverId: ServerId;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateServerChannelModalData = {
|
||||||
|
type: ModalType.CREATE_SERVER_CHANNEL;
|
||||||
|
data: {
|
||||||
|
serverId: ServerId;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ModalData = CreateServerChannelModalData | CreateServerInviteModalData | DeleteServerConfirmModalData;
|
||||||
|
|
||||||
|
interface ModalState {
|
||||||
|
type: ModalType | null;
|
||||||
|
data?: ModalData['data'];
|
||||||
|
isOpen: boolean;
|
||||||
|
onOpen: (type: ModalType, data?: ModalData['data']) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useModalStore = create<ModalState>()((set) => ({
|
||||||
|
type: null,
|
||||||
|
data: undefined,
|
||||||
|
isOpen: false,
|
||||||
|
onOpen: (type, data) => set({ type, data, isOpen: true }),
|
||||||
|
onClose: () => set({ type: null, isOpen: false }),
|
||||||
|
}));
|
||||||
25
app/stores/private-channels-store.ts
Normal file
25
app/stores/private-channels-store.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import { immer } from 'zustand/middleware/immer'
|
||||||
|
import type { ChannelId, RecipientChannel } from '~/lib/api/types'
|
||||||
|
|
||||||
|
type PrivateChannelsStore = {
|
||||||
|
channels: Record<ChannelId, RecipientChannel>
|
||||||
|
addChannels: (channels: RecipientChannel[]) => void
|
||||||
|
addChannel: (channel: RecipientChannel) => void
|
||||||
|
removeChannel: (channelId: ChannelId) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usePrivateChannelsStore = create<PrivateChannelsStore>()(
|
||||||
|
immer(
|
||||||
|
(set) => ({
|
||||||
|
channels: {},
|
||||||
|
addChannels: (channels: RecipientChannel[]) => set((state) => {
|
||||||
|
for (const channel of channels) {
|
||||||
|
state.channels[channel.id] = channel
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
addChannel: (channel: RecipientChannel) => set((state) => { state.channels[channel.id] = channel }),
|
||||||
|
removeChannel: (channelId: ChannelId) => set((state) => { delete state.channels[channelId] }),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
41
app/stores/server-channels-store.ts
Normal file
41
app/stores/server-channels-store.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import { immer } from 'zustand/middleware/immer'
|
||||||
|
import type { ChannelId, ServerChannel, ServerId } from "~/lib/api/types"
|
||||||
|
|
||||||
|
type ServerChannelsStore = {
|
||||||
|
channels: Record<ServerId, Record<ChannelId, ServerChannel>>
|
||||||
|
addServer: (serverId: ServerId) => void
|
||||||
|
addChannel: (channel: ServerChannel) => void
|
||||||
|
addChannels: (channels: ServerChannel[]) => void
|
||||||
|
removeChannel: (serverId: ServerId, channelId: ChannelId) => void
|
||||||
|
removeServer: (serverId: ServerId) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useServerChannelsStore = create<ServerChannelsStore>()(
|
||||||
|
immer(
|
||||||
|
(set, get) => ({
|
||||||
|
channels: {},
|
||||||
|
addServer: (serverId) => set((state) => {
|
||||||
|
state.channels[serverId] = {}
|
||||||
|
}),
|
||||||
|
addChannel: (channel) => set((state) => {
|
||||||
|
if (state.channels[channel.serverId] === undefined) {
|
||||||
|
state.channels[channel.serverId] = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
state.channels[channel.serverId][channel.id] = channel
|
||||||
|
}),
|
||||||
|
addChannels: (channels) => set((state) => {
|
||||||
|
for (const channel of channels) {
|
||||||
|
if (state.channels[channel.serverId] === undefined) {
|
||||||
|
state.channels[channel.serverId] = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
state.channels[channel.serverId][channel.id] = channel
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
removeChannel: (serverId, channelId) => set((state) => { delete state.channels[serverId][channelId] }),
|
||||||
|
removeServer: (serverId) => set((state) => { delete state.channels[serverId] }),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
25
app/stores/server-list-store.ts
Normal file
25
app/stores/server-list-store.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import { immer } from 'zustand/middleware/immer'
|
||||||
|
import type { Server, ServerId, Uuid } from '~/lib/api/types'
|
||||||
|
|
||||||
|
type ServerListStore = {
|
||||||
|
servers: Record<ServerId, Server>
|
||||||
|
addServers: (newServers: Server[]) => void
|
||||||
|
addServer: (server: Server) => void
|
||||||
|
removeServer: (serverId: Uuid) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useServerListStore = create<ServerListStore>()(
|
||||||
|
immer(
|
||||||
|
(set) => ({
|
||||||
|
servers: {},
|
||||||
|
addServers: (servers: Server[]) => set((state) => {
|
||||||
|
for (const server of servers) {
|
||||||
|
state.servers[server.id] = server
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
addServer: (server: Server) => set((state) => { state.servers[server.id] = server }),
|
||||||
|
removeServer: (serverId: Uuid) => set((state) => { delete state.servers[serverId] }),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
92
app/stores/users-store.tsx
Normal file
92
app/stores/users-store.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query"
|
||||||
|
import { create as batshitCreate, keyResolver } from "@yornaath/batshit"
|
||||||
|
import { create } from "zustand"
|
||||||
|
import { immer } from "zustand/middleware/immer"
|
||||||
|
import { getUser } from "~/lib/api/client/user"
|
||||||
|
import type { FullUser, PartialUser, UserId } from "~/lib/api/types"
|
||||||
|
|
||||||
|
type UsersStore = {
|
||||||
|
users: Record<UserId, PartialUser>
|
||||||
|
currentUserId: UserId | undefined
|
||||||
|
fetchUsersIfNotPresent: (userIds: UserId[]) => Promise<void>
|
||||||
|
addUser: (user: PartialUser) => void
|
||||||
|
removeUser: (userId: UserId) => void
|
||||||
|
setCurrentUserId: (userId: UserId) => void
|
||||||
|
getCurrentUser: () => FullUser | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const usersFetcher = batshitCreate({
|
||||||
|
fetcher: async (userIds: UserId[]) => {
|
||||||
|
let users = []
|
||||||
|
|
||||||
|
for (const userId of userIds) {
|
||||||
|
users.push(getUser(userId))
|
||||||
|
}
|
||||||
|
|
||||||
|
return await Promise.all(users)
|
||||||
|
},
|
||||||
|
resolver: keyResolver("id")
|
||||||
|
})
|
||||||
|
|
||||||
|
export const useUserQuery = (userId: UserId) => useQuery(
|
||||||
|
{
|
||||||
|
queryKey: ["users", userId],
|
||||||
|
queryFn: async () => {
|
||||||
|
const user = await getUser(userId)
|
||||||
|
return user
|
||||||
|
},
|
||||||
|
select: (data) => {
|
||||||
|
useUsersStore.getState().addUser(data)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export const useUsersStore = create<UsersStore>()(
|
||||||
|
immer(
|
||||||
|
(set, get) => ({
|
||||||
|
users: {},
|
||||||
|
currentUserId: undefined,
|
||||||
|
fetchUsersIfNotPresent: async (userIds) => {
|
||||||
|
let userPromises: Promise<PartialUser>[] = []
|
||||||
|
for (const userId of userIds) {
|
||||||
|
const user = get().users[userId]
|
||||||
|
if (!user) {
|
||||||
|
userPromises.push(usersFetcher.fetch(userId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const users = await Promise.all(userPromises)
|
||||||
|
const activeUsers = users.filter(Boolean)
|
||||||
|
|
||||||
|
set((state) => {
|
||||||
|
for (const user of activeUsers) {
|
||||||
|
if (user?.id)
|
||||||
|
state.users[user.id] = user
|
||||||
|
}
|
||||||
|
|
||||||
|
})
|
||||||
|
},
|
||||||
|
addUser: (user) => set((state) => {
|
||||||
|
if (user.id !== get().currentUserId)
|
||||||
|
state.users[user.id] = user
|
||||||
|
else {
|
||||||
|
const currentUser = get().users[user.id]
|
||||||
|
if (currentUser)
|
||||||
|
state.users[user.id] = { ...currentUser, ...user }
|
||||||
|
else
|
||||||
|
state.users[user.id] = user
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
removeUser: (userId) => set((state) => {
|
||||||
|
delete state.users[userId]
|
||||||
|
}),
|
||||||
|
|
||||||
|
setCurrentUserId: (userId) => set((state) => {
|
||||||
|
state.currentUserId = userId
|
||||||
|
}),
|
||||||
|
|
||||||
|
getCurrentUser: () => !!get().currentUserId ? get().users[get().currentUserId!] as FullUser : undefined
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
)
|
||||||
46
app/stores/voice-state-store.ts
Normal file
46
app/stores/voice-state-store.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { useWebRTCStore } from './webrtc-store';
|
||||||
|
|
||||||
|
interface VoiceState {
|
||||||
|
activeChannel: { serverId: string; channelId: string } | null;
|
||||||
|
error: string | null;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
joinVoiceChannel: (serverId: string, channelId: string) => void;
|
||||||
|
leaveVoiceChannel: () => void;
|
||||||
|
setError: (error: string) => void;
|
||||||
|
resetError: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useVoiceStateStore = create<VoiceState>()((set, get) => {
|
||||||
|
return {
|
||||||
|
activeChannel: null,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
joinVoiceChannel: (serverId, channelId) => {
|
||||||
|
set({
|
||||||
|
activeChannel: { serverId, channelId },
|
||||||
|
error: null
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
leaveVoiceChannel: () => {
|
||||||
|
const currentState = get();
|
||||||
|
if (currentState.activeChannel) {
|
||||||
|
useWebRTCStore.getState().disconnect();
|
||||||
|
|
||||||
|
set({
|
||||||
|
activeChannel: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setError: (error) => {
|
||||||
|
set({ error });
|
||||||
|
},
|
||||||
|
|
||||||
|
resetError: () => {
|
||||||
|
set({ error: null });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
54
app/stores/webrtc-store.ts
Normal file
54
app/stores/webrtc-store.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { WebRTCClient } from '~/lib/websocket/voice/client';
|
||||||
|
import { ConnectionState } from '~/lib/websocket/voice/types';
|
||||||
|
import { useVoiceStateStore } from './voice-state-store';
|
||||||
|
|
||||||
|
const VOICE_GATEWAY_URL = 'ws://localhost:12345/voice/ws';
|
||||||
|
|
||||||
|
interface WebRTCState {
|
||||||
|
client: WebRTCClient | null;
|
||||||
|
status: ConnectionState;
|
||||||
|
remoteStream: MediaStream | null;
|
||||||
|
error: string | null;
|
||||||
|
connect: (token: string) => Promise<void>;
|
||||||
|
disconnect: () => void;
|
||||||
|
createOffer: (localStream: MediaStream) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useWebRTCStore = create<WebRTCState>()((set, get) => {
|
||||||
|
const client = new WebRTCClient(
|
||||||
|
VOICE_GATEWAY_URL,
|
||||||
|
(state) => set({ status: state }),
|
||||||
|
(error) => {
|
||||||
|
set({
|
||||||
|
status: ConnectionState.ERROR,
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
useVoiceStateStore.getState().setError(error.message);
|
||||||
|
},
|
||||||
|
(stream) => set({ remoteStream: stream })
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
client,
|
||||||
|
status: ConnectionState.DISCONNECTED,
|
||||||
|
remoteStream: null,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
connect: async (token) => {
|
||||||
|
await client.connect(token);
|
||||||
|
},
|
||||||
|
|
||||||
|
disconnect: () => {
|
||||||
|
client.disconnect();
|
||||||
|
set({
|
||||||
|
status: ConnectionState.DISCONNECTED,
|
||||||
|
remoteStream: null
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
createOffer: async (localStream) => {
|
||||||
|
await client.createOffer(localStream);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
118
bun.lock
118
bun.lock
@@ -5,41 +5,45 @@
|
|||||||
"name": "diplom",
|
"name": "diplom",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^5.0.1",
|
"@hookform/resolvers": "^5.0.1",
|
||||||
|
"@radix-ui/react-aspect-ratio": "^1.1.6",
|
||||||
"@radix-ui/react-avatar": "^1.1.9",
|
"@radix-ui/react-avatar": "^1.1.9",
|
||||||
"@radix-ui/react-dialog": "^1.1.13",
|
"@radix-ui/react-dialog": "^1.1.13",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.14",
|
"@radix-ui/react-dropdown-menu": "^2.1.14",
|
||||||
"@radix-ui/react-label": "^2.1.6",
|
"@radix-ui/react-label": "^2.1.6",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.8",
|
"@radix-ui/react-scroll-area": "^1.2.8",
|
||||||
|
"@radix-ui/react-select": "^2.2.4",
|
||||||
"@radix-ui/react-separator": "^1.1.6",
|
"@radix-ui/react-separator": "^1.1.6",
|
||||||
"@radix-ui/react-slot": "^1.2.2",
|
"@radix-ui/react-slot": "^1.2.2",
|
||||||
"@radix-ui/react-tabs": "^1.1.11",
|
"@radix-ui/react-tabs": "^1.1.11",
|
||||||
"@radix-ui/react-tooltip": "^1.2.6",
|
"@radix-ui/react-tooltip": "^1.2.6",
|
||||||
"@react-router/node": "^7.5.3",
|
"@react-router/node": "^7.6.0",
|
||||||
"@react-router/serve": "^7.5.3",
|
"@react-router/serve": "^7.6.0",
|
||||||
|
"@tanstack/react-query": "^5.76.1",
|
||||||
|
"@yornaath/batshit": "^0.10.1",
|
||||||
"axios": "^1.9.0",
|
"axios": "^1.9.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"immer": "^10.1.1",
|
"immer": "^10.1.1",
|
||||||
"isbot": "^5.1.27",
|
"isbot": "^5.1.28",
|
||||||
"lucide-react": "^0.508.0",
|
"lucide-react": "^0.510.0",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-hook-form": "^7.56.3",
|
"react-hook-form": "^7.56.3",
|
||||||
"react-router": "^7.5.3",
|
"react-router": "^7.6.0",
|
||||||
"tailwind-merge": "^3.2.0",
|
"tailwind-merge": "^3.3.0",
|
||||||
"zod": "^3.24.4",
|
"zod": "^3.24.4",
|
||||||
"zustand": "^5.0.4",
|
"zustand": "^5.0.4",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@react-router/dev": "^7.5.3",
|
"@react-router/dev": "^7.6.0",
|
||||||
"@tailwindcss/vite": "^4.1.4",
|
"@tailwindcss/vite": "^4.1.6",
|
||||||
"@types/node": "^20",
|
"@types/node": "^22.15.18",
|
||||||
"@types/react": "^19.1.2",
|
"@types/react": "^19.1.4",
|
||||||
"@types/react-dom": "^19.1.2",
|
"@types/react-dom": "^19.1.5",
|
||||||
"tailwindcss": "^4.1.4",
|
"tailwindcss": "^4.1.6",
|
||||||
"tw-animate-css": "^1.2.9",
|
"tw-animate-css": "^1.2.9",
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.8.3",
|
||||||
"vite": "^6.3.3",
|
"vite": "^6.3.5",
|
||||||
"vite-tsconfig-paths": "^5.1.4",
|
"vite-tsconfig-paths": "^5.1.4",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -165,6 +169,8 @@
|
|||||||
|
|
||||||
"@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
|
"@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
|
||||||
|
|
||||||
|
"@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="],
|
||||||
|
|
||||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.8", "", { "dependencies": { "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA=="],
|
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.8", "", { "dependencies": { "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA=="],
|
||||||
|
|
||||||
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||||
@@ -191,6 +197,8 @@
|
|||||||
|
|
||||||
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.6", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-2JMfHJf/eVnwq+2dewT3C0acmCWD3XiVA1Da+jTDqo342UlU13WvXtqHhG+yJw5JeQmu4ue2eMy6gcEArLBlcw=="],
|
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.6", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-2JMfHJf/eVnwq+2dewT3C0acmCWD3XiVA1Da+jTDqo342UlU13WvXtqHhG+yJw5JeQmu4ue2eMy6gcEArLBlcw=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-aspect-ratio": ["@radix-ui/react-aspect-ratio@1.1.6", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-cZvNiIKqWQjf3DsQk1+wktF3DD73kUbWQ2E/XSh8m2IcpFGwg4IiIvGlVNdovxuozK/9+4QXd2zVlzUMiexSDg=="],
|
||||||
|
|
||||||
"@radix-ui/react-avatar": ["@radix-ui/react-avatar@1.1.9", "", { "dependencies": { "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.2", "@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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-10tQokfvZdFvnvDkcOJPjm2pWiP8A0R4T83MoD7tb15bC/k2GU7B1YBuzJi8lNQ8V1QqhP8ocNqp27ByZaNagQ=="],
|
"@radix-ui/react-avatar": ["@radix-ui/react-avatar@1.1.9", "", { "dependencies": { "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.2", "@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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-10tQokfvZdFvnvDkcOJPjm2pWiP8A0R4T83MoD7tb15bC/k2GU7B1YBuzJi8lNQ8V1QqhP8ocNqp27ByZaNagQ=="],
|
||||||
|
|
||||||
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.6", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.2", "@radix-ui/react-slot": "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-PbhRFK4lIEw9ADonj48tiYWzkllz81TM7KVYyyMMw2cwHO7D5h4XKEblL8NlaRisTK3QTe6tBEhDccFUryxHBQ=="],
|
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.6", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.2", "@radix-ui/react-slot": "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-PbhRFK4lIEw9ADonj48tiYWzkllz81TM7KVYyyMMw2cwHO7D5h4XKEblL8NlaRisTK3QTe6tBEhDccFUryxHBQ=="],
|
||||||
@@ -229,6 +237,8 @@
|
|||||||
|
|
||||||
"@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.8", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.2", "@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.4", "@radix-ui/react-primitive": "2.1.2", "@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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-K5h1RkYA6M0Sn61BV5LQs686zqBsSC0sGzL4/Gw4mNnjzrQcGSc6YXfC6CRFNaGydSdv5+M8cb0eNsOGo0OXtQ=="],
|
"@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.8", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.2", "@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.4", "@radix-ui/react-primitive": "2.1.2", "@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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-K5h1RkYA6M0Sn61BV5LQs686zqBsSC0sGzL4/Gw4mNnjzrQcGSc6YXfC6CRFNaGydSdv5+M8cb0eNsOGo0OXtQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-select": ["@radix-ui/react-select@2.2.4", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.6", "@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.9", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.6", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.6", "@radix-ui/react-portal": "1.1.8", "@radix-ui/react-primitive": "2.1.2", "@radix-ui/react-slot": "1.2.2", "@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.2", "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/OOm58Gil4Ev5zT8LyVzqfBcij4dTHYdeyuF5lMHZ2bIp0Lk9oETocYiJ5QC0dHekEQnK6L/FNJCceeb4AkZ6Q=="],
|
||||||
|
|
||||||
"@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.6", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Izof3lPpbCfTM7WDta+LRkz31jem890VjEvpVRoWQNKpDUMMVffuyq854XPGP1KYGWWmjmYvHvPFeocWhFCy1w=="],
|
"@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.6", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Izof3lPpbCfTM7WDta+LRkz31jem890VjEvpVRoWQNKpDUMMVffuyq854XPGP1KYGWWmjmYvHvPFeocWhFCy1w=="],
|
||||||
|
|
||||||
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.2", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-y7TBO4xN4Y94FvcWIOIh18fM4R1A8S4q1jhoz4PNzOoHsFcN8pogcFmZrTYAm4F9VRUrWP/Mw7xSKybIeRI+CQ=="],
|
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.2", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-y7TBO4xN4Y94FvcWIOIh18fM4R1A8S4q1jhoz4PNzOoHsFcN8pogcFmZrTYAm4F9VRUrWP/Mw7xSKybIeRI+CQ=="],
|
||||||
@@ -249,6 +259,8 @@
|
|||||||
|
|
||||||
"@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="],
|
"@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ=="],
|
||||||
|
|
||||||
"@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="],
|
"@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="],
|
||||||
|
|
||||||
"@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="],
|
"@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="],
|
||||||
@@ -257,13 +269,13 @@
|
|||||||
|
|
||||||
"@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
|
"@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
|
||||||
|
|
||||||
"@react-router/dev": ["@react-router/dev@7.5.3", "", { "dependencies": { "@babel/core": "^7.21.8", "@babel/generator": "^7.21.5", "@babel/parser": "^7.21.8", "@babel/plugin-syntax-decorators": "^7.22.10", "@babel/plugin-syntax-jsx": "^7.21.4", "@babel/preset-typescript": "^7.21.5", "@babel/traverse": "^7.23.2", "@babel/types": "^7.22.5", "@npmcli/package-json": "^4.0.1", "@react-router/node": "7.5.3", "arg": "^5.0.1", "babel-dead-code-elimination": "^1.0.6", "chokidar": "^4.0.0", "dedent": "^1.5.3", "es-module-lexer": "^1.3.1", "exit-hook": "2.2.1", "fs-extra": "^10.0.0", "jsesc": "3.0.2", "lodash": "^4.17.21", "pathe": "^1.1.2", "picocolors": "^1.1.1", "prettier": "^2.7.1", "react-refresh": "^0.14.0", "semver": "^7.3.7", "set-cookie-parser": "^2.6.0", "valibot": "^0.41.0", "vite-node": "3.0.0-beta.2" }, "peerDependencies": { "@react-router/serve": "^7.5.3", "react-router": "^7.5.3", "typescript": "^5.1.0", "vite": "^5.1.0 || ^6.0.0", "wrangler": "^3.28.2 || ^4.0.0" }, "optionalPeers": ["@react-router/serve", "typescript", "wrangler"], "bin": { "react-router": "bin.js" } }, "sha512-U+n8JYAREKg6eHIAXCjazsYlwPo/vcAbShpqePnDBUdDnePBwZ2JmoqhWV+7tIhyHvvHGQKlw6BcrSZtF549WQ=="],
|
"@react-router/dev": ["@react-router/dev@7.6.0", "", { "dependencies": { "@babel/core": "^7.21.8", "@babel/generator": "^7.21.5", "@babel/parser": "^7.21.8", "@babel/plugin-syntax-decorators": "^7.22.10", "@babel/plugin-syntax-jsx": "^7.21.4", "@babel/preset-typescript": "^7.21.5", "@babel/traverse": "^7.23.2", "@babel/types": "^7.22.5", "@npmcli/package-json": "^4.0.1", "@react-router/node": "7.6.0", "arg": "^5.0.1", "babel-dead-code-elimination": "^1.0.6", "chokidar": "^4.0.0", "dedent": "^1.5.3", "es-module-lexer": "^1.3.1", "exit-hook": "2.2.1", "fs-extra": "^10.0.0", "jsesc": "3.0.2", "lodash": "^4.17.21", "pathe": "^1.1.2", "picocolors": "^1.1.1", "prettier": "^2.7.1", "react-refresh": "^0.14.0", "semver": "^7.3.7", "set-cookie-parser": "^2.6.0", "valibot": "^0.41.0", "vite-node": "3.0.0-beta.2" }, "peerDependencies": { "@react-router/serve": "^7.6.0", "react-router": "^7.6.0", "typescript": "^5.1.0", "vite": "^5.1.0 || ^6.0.0", "wrangler": "^3.28.2 || ^4.0.0" }, "optionalPeers": ["@react-router/serve", "typescript", "wrangler"], "bin": { "react-router": "bin.js" } }, "sha512-XSxEslex0ddJPxNNgdU1Eqmc9lsY/lhcLNCcRLAtlrOPyOz3Y8kIPpAf5T/U2AG3HGXFVBa9f8aQ7wXU3wTJSw=="],
|
||||||
|
|
||||||
"@react-router/express": ["@react-router/express@7.5.3", "", { "dependencies": { "@react-router/node": "7.5.3" }, "peerDependencies": { "express": "^4.17.1 || ^5", "react-router": "7.5.3", "typescript": "^5.1.0" }, "optionalPeers": ["typescript"] }, "sha512-/fPrmeJQME7nL71FyUAMRGZk1PsrW1+hPMs7caIATmHKvE8hArd2BrpdsHEbGNvirnyO+qABQJmRFdNtCU/axQ=="],
|
"@react-router/express": ["@react-router/express@7.6.0", "", { "dependencies": { "@react-router/node": "7.6.0" }, "peerDependencies": { "express": "^4.17.1 || ^5", "react-router": "7.6.0", "typescript": "^5.1.0" }, "optionalPeers": ["typescript"] }, "sha512-nxSTCcTsVx94bXOI9JjG7Cg338myi8EdQWTOjA97v2ApX35wZm/ZDYos5MbrvZiMi0aB4KgAD62o4byNqF9Z1A=="],
|
||||||
|
|
||||||
"@react-router/node": ["@react-router/node@7.5.3", "", { "dependencies": { "@mjackson/node-fetch-server": "^0.2.0", "source-map-support": "^0.5.21", "stream-slice": "^0.1.2", "undici": "^6.19.2" }, "peerDependencies": { "react-router": "7.5.3", "typescript": "^5.1.0" }, "optionalPeers": ["typescript"] }, "sha512-w85YL6UngvhxGmgTBL3qwlVLFiA1EzNyG5S55qGcnhDikfb9z84gqMlTU4UqlZm8PjQ021HPTTN6I3kd7QE5sg=="],
|
"@react-router/node": ["@react-router/node@7.6.0", "", { "dependencies": { "@mjackson/node-fetch-server": "^0.2.0", "source-map-support": "^0.5.21", "stream-slice": "^0.1.2", "undici": "^6.19.2" }, "peerDependencies": { "react-router": "7.6.0", "typescript": "^5.1.0" }, "optionalPeers": ["typescript"] }, "sha512-agjDPUzisLdGJ7Q2lx/Z3OfdS2t1k6qv/nTvA45iahGsQJCMDvMqVoIi7iIULKQJwrn4HWjM9jqEp75+WsMOXg=="],
|
||||||
|
|
||||||
"@react-router/serve": ["@react-router/serve@7.5.3", "", { "dependencies": { "@react-router/express": "7.5.3", "@react-router/node": "7.5.3", "compression": "^1.7.4", "express": "^4.19.2", "get-port": "5.1.1", "morgan": "^1.10.0", "source-map-support": "^0.5.21" }, "peerDependencies": { "react-router": "7.5.3" }, "bin": { "react-router-serve": "bin.js" } }, "sha512-0YkoVAJWAMtYPKQVXKb1RoSfy3EbjyzEeaFCL4twB4eB5Rj4MSE9uH0Zb9caNQXpq/8MDzjbe3SuvQ8uqwyqkQ=="],
|
"@react-router/serve": ["@react-router/serve@7.6.0", "", { "dependencies": { "@react-router/express": "7.6.0", "@react-router/node": "7.6.0", "compression": "^1.7.4", "express": "^4.19.2", "get-port": "5.1.1", "morgan": "^1.10.0", "source-map-support": "^0.5.21" }, "peerDependencies": { "react-router": "7.6.0" }, "bin": { "react-router-serve": "bin.js" } }, "sha512-2O8ALEYgJfimvEdNRqMpnZb2N+DQ5UK/SKo9Xo3mTkt3no0rNTcNxzmhzD2tm92Q/HI7kHmMY1nBegNB2i1abA=="],
|
||||||
|
|
||||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.40.2", "", { "os": "android", "cpu": "arm" }, "sha512-JkdNEq+DFxZfUwxvB58tHMHBHVgX23ew41g1OQinthJ+ryhdRk67O31S7sYw8u2lTjHUPFxwar07BBt1KHp/hg=="],
|
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.40.2", "", { "os": "android", "cpu": "arm" }, "sha512-JkdNEq+DFxZfUwxvB58tHMHBHVgX23ew41g1OQinthJ+ryhdRk67O31S7sYw8u2lTjHUPFxwar07BBt1KHp/hg=="],
|
||||||
|
|
||||||
@@ -307,43 +319,51 @@
|
|||||||
|
|
||||||
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
|
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
|
||||||
|
|
||||||
"@tailwindcss/node": ["@tailwindcss/node@4.1.5", "", { "dependencies": { "enhanced-resolve": "^5.18.1", "jiti": "^2.4.2", "lightningcss": "1.29.2", "tailwindcss": "4.1.5" } }, "sha512-CBhSWo0vLnWhXIvpD0qsPephiaUYfHUX3U9anwDaHZAeuGpTiB3XmsxPAN6qX7bFhipyGBqOa1QYQVVhkOUGxg=="],
|
"@tailwindcss/node": ["@tailwindcss/node@4.1.6", "", { "dependencies": { "@ampproject/remapping": "^2.3.0", "enhanced-resolve": "^5.18.1", "jiti": "^2.4.2", "lightningcss": "1.29.2", "magic-string": "^0.30.17", "source-map-js": "^1.2.1", "tailwindcss": "4.1.6" } }, "sha512-ed6zQbgmKsjsVvodAS1q1Ld2BolEuxJOSyyNc+vhkjdmfNUDCmQnlXBfQkHrlzNmslxHsQU/bFmzcEbv4xXsLg=="],
|
||||||
|
|
||||||
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.5", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.5", "@tailwindcss/oxide-darwin-arm64": "4.1.5", "@tailwindcss/oxide-darwin-x64": "4.1.5", "@tailwindcss/oxide-freebsd-x64": "4.1.5", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.5", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.5", "@tailwindcss/oxide-linux-arm64-musl": "4.1.5", "@tailwindcss/oxide-linux-x64-gnu": "4.1.5", "@tailwindcss/oxide-linux-x64-musl": "4.1.5", "@tailwindcss/oxide-wasm32-wasi": "4.1.5", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.5", "@tailwindcss/oxide-win32-x64-msvc": "4.1.5" } }, "sha512-1n4br1znquEvyW/QuqMKQZlBen+jxAbvyduU87RS8R3tUSvByAkcaMTkJepNIrTlYhD+U25K4iiCIxE6BGdRYA=="],
|
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.6", "", { "dependencies": { "detect-libc": "^2.0.4", "tar": "^7.4.3" }, "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.6", "@tailwindcss/oxide-darwin-arm64": "4.1.6", "@tailwindcss/oxide-darwin-x64": "4.1.6", "@tailwindcss/oxide-freebsd-x64": "4.1.6", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.6", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.6", "@tailwindcss/oxide-linux-arm64-musl": "4.1.6", "@tailwindcss/oxide-linux-x64-gnu": "4.1.6", "@tailwindcss/oxide-linux-x64-musl": "4.1.6", "@tailwindcss/oxide-wasm32-wasi": "4.1.6", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.6", "@tailwindcss/oxide-win32-x64-msvc": "4.1.6" } }, "sha512-0bpEBQiGx+227fW4G0fLQ8vuvyy5rsB1YIYNapTq3aRsJ9taF3f5cCaovDjN5pUGKKzcpMrZst/mhNaKAPOHOA=="],
|
||||||
|
|
||||||
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.5", "", { "os": "android", "cpu": "arm64" }, "sha512-LVvM0GirXHED02j7hSECm8l9GGJ1RfgpWCW+DRn5TvSaxVsv28gRtoL4aWKGnXqwvI3zu1GABeDNDVZeDPOQrw=="],
|
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.6", "", { "os": "android", "cpu": "arm64" }, "sha512-VHwwPiwXtdIvOvqT/0/FLH/pizTVu78FOnI9jQo64kSAikFSZT7K4pjyzoDpSMaveJTGyAKvDjuhxJxKfmvjiQ=="],
|
||||||
|
|
||||||
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-//TfCA3pNrgnw4rRJOqavW7XUk8gsg9ddi8cwcsWXp99tzdBAZW0WXrD8wDyNbqjW316Pk2hiN/NJx/KWHl8oA=="],
|
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-weINOCcqv1HVBIGptNrk7c6lWgSFFiQMcCpKM4tnVi5x8OY2v1FrV76jwLukfT6pL1hyajc06tyVmZFYXoxvhQ=="],
|
||||||
|
|
||||||
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-XQorp3Q6/WzRd9OalgHgaqgEbjP3qjHrlSUb5k1EuS1Z9NE9+BbzSORraO+ecW432cbCN7RVGGL/lSnHxcd+7Q=="],
|
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-3FzekhHG0ww1zQjQ1lPoq0wPrAIVXAbUkWdWM8u5BnYFZgb9ja5ejBqyTgjpo5mfy0hFOoMnMuVDI+7CXhXZaQ=="],
|
||||||
|
|
||||||
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-bPrLWbxo8gAo97ZmrCbOdtlz/Dkuy8NK97aFbVpkJ2nJ2Jo/rsCbu0TlGx8joCuA3q6vMWTSn01JY46iwG+clg=="],
|
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.6", "", { "os": "freebsd", "cpu": "x64" }, "sha512-4m5F5lpkBZhVQJq53oe5XgJ+aFYWdrgkMwViHjRsES3KEu2m1udR21B1I77RUqie0ZYNscFzY1v9aDssMBZ/1w=="],
|
||||||
|
|
||||||
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.5", "", { "os": "linux", "cpu": "arm" }, "sha512-1gtQJY9JzMAhgAfvd/ZaVOjh/Ju/nCoAsvOVJenWZfs05wb8zq+GOTnZALWGqKIYEtyNpCzvMk+ocGpxwdvaVg=="],
|
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.6", "", { "os": "linux", "cpu": "arm" }, "sha512-qU0rHnA9P/ZoaDKouU1oGPxPWzDKtIfX7eOGi5jOWJKdxieUJdVV+CxWZOpDWlYTd4N3sFQvcnVLJWJ1cLP5TA=="],
|
||||||
|
|
||||||
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-dtlaHU2v7MtdxBXoqhxwsWjav7oim7Whc6S9wq/i/uUMTWAzq/gijq1InSgn2yTnh43kR+SFvcSyEF0GCNu1PQ=="],
|
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-jXy3TSTrbfgyd3UxPQeXC3wm8DAgmigzar99Km9Sf6L2OFfn/k+u3VqmpgHQw5QNfCpPe43em6Q7V76Wx7ogIQ=="],
|
||||||
|
|
||||||
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-fg0F6nAeYcJ3CriqDT1iVrqALMwD37+sLzXs8Rjy8Z1ZHshJoYceodfyUwGJEsQoTyWbliFNRs2wMQNXtT7MVA=="],
|
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-8kjivE5xW0qAQ9HX9reVFmZj3t+VmljDLVRJpVBEoTR+3bKMnvC7iLcoSGNIUJGOZy1mLVq7x/gerVg0T+IsYw=="],
|
||||||
|
|
||||||
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.5", "", { "os": "linux", "cpu": "x64" }, "sha512-SO+F2YEIAHa1AITwc8oPwMOWhgorPzzcbhWEb+4oLi953h45FklDmM8dPSZ7hNHpIk9p/SCZKUYn35t5fjGtHA=="],
|
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.6", "", { "os": "linux", "cpu": "x64" }, "sha512-A4spQhwnWVpjWDLXnOW9PSinO2PTKJQNRmL/aIl2U/O+RARls8doDfs6R41+DAXK0ccacvRyDpR46aVQJJCoCg=="],
|
||||||
|
|
||||||
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.5", "", { "os": "linux", "cpu": "x64" }, "sha512-6UbBBplywkk/R+PqqioskUeXfKcBht3KU7juTi1UszJLx0KPXUo10v2Ok04iBJIaDPkIFkUOVboXms5Yxvaz+g=="],
|
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.6", "", { "os": "linux", "cpu": "x64" }, "sha512-YRee+6ZqdzgiQAHVSLfl3RYmqeeaWVCk796MhXhLQu2kJu2COHBkqlqsqKYx3p8Hmk5pGCQd2jTAoMWWFeyG2A=="],
|
||||||
|
|
||||||
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.5", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@emnapi/wasi-threads": "^1.0.2", "@napi-rs/wasm-runtime": "^0.2.9", "@tybys/wasm-util": "^0.9.0", "tslib": "^2.8.0" }, "cpu": "none" }, "sha512-hwALf2K9FHuiXTPqmo1KeOb83fTRNbe9r/Ixv9ZNQ/R24yw8Ge1HOWDDgTdtzntIaIUJG5dfXCf4g9AD4RiyhQ=="],
|
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.6", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@emnapi/wasi-threads": "^1.0.2", "@napi-rs/wasm-runtime": "^0.2.9", "@tybys/wasm-util": "^0.9.0", "tslib": "^2.8.0" }, "cpu": "none" }, "sha512-qAp4ooTYrBQ5pk5jgg54/U1rCJ/9FLYOkkQ/nTE+bVMseMfB6O7J8zb19YTpWuu4UdfRf5zzOrNKfl6T64MNrQ=="],
|
||||||
|
|
||||||
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-oDKncffWzaovJbkuR7/OTNFRJQVdiw/n8HnzaCItrNQUeQgjy7oUiYpsm9HUBgpmvmDpSSbGaCa2Evzvk3eFmA=="],
|
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-nqpDWk0Xr8ELO/nfRUDjk1pc9wDJ3ObeDdNMHLaymc4PJBWj11gdPCWZFKSK2AVKjJQC7J2EfmSmf47GN7OuLg=="],
|
||||||
|
|
||||||
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.5", "", { "os": "win32", "cpu": "x64" }, "sha512-WiR4dtyrFdbb+ov0LK+7XsFOsG+0xs0PKZKkt41KDn9jYpO7baE3bXiudPVkTqUEwNfiglCygQHl2jklvSBi7Q=="],
|
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.6", "", { "os": "win32", "cpu": "x64" }, "sha512-5k9xF33xkfKpo9wCvYcegQ21VwIBU1/qEbYlVukfEIyQbEA47uK8AAwS7NVjNE3vHzcmxMYwd0l6L4pPjjm1rQ=="],
|
||||||
|
|
||||||
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.5", "", { "dependencies": { "@tailwindcss/node": "4.1.5", "@tailwindcss/oxide": "4.1.5", "tailwindcss": "4.1.5" }, "peerDependencies": { "vite": "^5.2.0 || ^6" } }, "sha512-FE1stRoqdHSb7RxesMfCXE8icwI1W6zGE/512ae3ZDrpkQYTTYeSyUJPRCjZd8CwVAhpDUbi1YR8pcZioFJQ/w=="],
|
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.6", "", { "dependencies": { "@tailwindcss/node": "4.1.6", "@tailwindcss/oxide": "4.1.6", "tailwindcss": "4.1.6" }, "peerDependencies": { "vite": "^5.2.0 || ^6" } }, "sha512-zjtqjDeY1w3g2beYQtrMAf51n5G7o+UwmyOjtsDMP7t6XyoRMOidcoKP32ps7AkNOHIXEOK0bhIC05dj8oJp4w=="],
|
||||||
|
|
||||||
|
"@tanstack/query-core": ["@tanstack/query-core@5.76.0", "", {}, "sha512-FN375hb8ctzfNAlex5gHI6+WDXTNpe0nbxp/d2YJtnP+IBM6OUm7zcaoCW6T63BawGOYZBbKC0iPvr41TteNVg=="],
|
||||||
|
|
||||||
|
"@tanstack/react-query": ["@tanstack/react-query@5.76.1", "", { "dependencies": { "@tanstack/query-core": "5.76.0" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-YxdLZVGN4QkT5YT1HKZQWiIlcgauIXEIsMOTSjvyD5wLYK8YVvKZUPAysMqossFJJfDpJW3pFn7WNZuPOqq+fw=="],
|
||||||
|
|
||||||
"@types/estree": ["@types/estree@1.0.7", "", {}, "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="],
|
"@types/estree": ["@types/estree@1.0.7", "", {}, "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="],
|
||||||
|
|
||||||
"@types/node": ["@types/node@20.17.45", "", { "dependencies": { "undici-types": "~6.19.2" } }, "sha512-vO9+E1smq+149wsmmLdM8SKVW7gRzLjfo0mU7kiykhV6rL+GEUhUmW7VywJNSxJHQzt9QBIHEo+3SG4MrFTqbA=="],
|
"@types/node": ["@types/node@22.15.18", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-v1DKRfUdyW+jJhZNEI1PYy29S2YRxMV5AOO/x/SjKmW0acCIOqmbj6Haf9eHAhsPmrhlHSxEhv/1WszcLWV4cg=="],
|
||||||
|
|
||||||
"@types/react": ["@types/react@19.1.3", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-dLWQ+Z0CkIvK1J8+wrDPwGxEYFA4RAyHoZPxHVGspYmFVnwGSNT24cGIhFJrtfRnWVuW8X7NO52gCXmhkVUWGQ=="],
|
"@types/react": ["@types/react@19.1.4", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-EB1yiiYdvySuIITtD5lhW4yPyJ31RkJkkDw794LaQYrxCSaQV/47y5o1FMC4zF9ZyjUjzJMZwbovEnT5yHTW6g=="],
|
||||||
|
|
||||||
"@types/react-dom": ["@types/react-dom@19.1.3", "", { "peerDependencies": { "@types/react": "^19.0.0" } }, "sha512-rJXC08OG0h3W6wDMFxQrZF00Kq6qQvw0djHRdzl3U5DnIERz0MRce3WVc7IS6JYBwtaP/DwYtRRjVlvivNveKg=="],
|
"@types/react-dom": ["@types/react-dom@19.1.5", "", { "peerDependencies": { "@types/react": "^19.0.0" } }, "sha512-CMCjrWucUBZvohgZxkjd6S9h0nZxXjzus6yDfUb+xLxYM7VvjKNH1tQrE9GWLql1XoOP4/Ds3bwFqShHUYraGg=="],
|
||||||
|
|
||||||
|
"@yornaath/batshit": ["@yornaath/batshit@0.10.1", "", { "dependencies": { "@yornaath/batshit-devtools": "^1.7.1" } }, "sha512-WGZ1WNoiVN6CLf28O73+6SCf+2lUn4U7TLGM9f4zOad0pn9mdoXIq8cwu3Kpf7N2OTYgWGK4eQPTflwFlduDGA=="],
|
||||||
|
|
||||||
|
"@yornaath/batshit-devtools": ["@yornaath/batshit-devtools@1.7.1", "", {}, "sha512-AyttV1Njj5ug+XqEWY1smV45dTWMlWKtj1B8jcFYgBKUFyUlF/qEhD+iP1E5UaRYW6hQRYD9T2WNDwFTrOMWzQ=="],
|
||||||
|
|
||||||
"accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
|
"accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
|
||||||
|
|
||||||
@@ -387,6 +407,8 @@
|
|||||||
|
|
||||||
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
|
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
|
||||||
|
|
||||||
|
"chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
|
||||||
|
|
||||||
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
|
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
|
||||||
|
|
||||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||||
@@ -529,7 +551,7 @@
|
|||||||
|
|
||||||
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
|
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
|
||||||
|
|
||||||
"isbot": ["isbot@5.1.27", "", {}, "sha512-V3W56Hnztt4Wdh3VUlAMbdNicX/tOM38eChW3a2ixP6KEBJAeehxzYzTD59JrU5NCTgBZwRt9lRWr8D7eMZVYQ=="],
|
"isbot": ["isbot@5.1.28", "", {}, "sha512-qrOp4g3xj8YNse4biorv6O5ZShwsJM0trsoda4y7j/Su7ZtTTfVXFzbKkpgcSoDrHS8FcTuUwcU04YimZlZOxw=="],
|
||||||
|
|
||||||
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||||
|
|
||||||
@@ -573,7 +595,9 @@
|
|||||||
|
|
||||||
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||||
|
|
||||||
"lucide-react": ["lucide-react@0.508.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-gcP16PnexqtOFrTtv98kVsGzTfnbPekzZiQfByi2S89xfk7E/4uKE1USZqccIp58v42LqkO7MuwpCqshwSrJCg=="],
|
"lucide-react": ["lucide-react@0.510.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-p8SQRAMVh7NhsAIETokSqDrc5CHnDLbV29mMnzaXx+Vc/hnqQzwI2r0FMWCcoTXnbw2KEjy48xwpGdEL+ck06Q=="],
|
||||||
|
|
||||||
|
"magic-string": ["magic-string@0.30.17", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" } }, "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA=="],
|
||||||
|
|
||||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||||
|
|
||||||
@@ -593,6 +617,10 @@
|
|||||||
|
|
||||||
"minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="],
|
"minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="],
|
||||||
|
|
||||||
|
"minizlib": ["minizlib@3.0.2", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA=="],
|
||||||
|
|
||||||
|
"mkdirp": ["mkdirp@3.0.1", "", { "bin": { "mkdirp": "dist/cjs/src/bin.js" } }, "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg=="],
|
||||||
|
|
||||||
"morgan": ["morgan@1.10.0", "", { "dependencies": { "basic-auth": "~2.0.1", "debug": "2.6.9", "depd": "~2.0.0", "on-finished": "~2.3.0", "on-headers": "~1.0.2" } }, "sha512-AbegBVI4sh6El+1gNwvD5YIck7nSA36weD7xvIxG4in80j/UoK8AEGaWnnz8v1GxonMCltmlNs5ZKbGvl9b1XQ=="],
|
"morgan": ["morgan@1.10.0", "", { "dependencies": { "basic-auth": "~2.0.1", "debug": "2.6.9", "depd": "~2.0.0", "on-finished": "~2.3.0", "on-headers": "~1.0.2" } }, "sha512-AbegBVI4sh6El+1gNwvD5YIck7nSA36weD7xvIxG4in80j/UoK8AEGaWnnz8v1GxonMCltmlNs5ZKbGvl9b1XQ=="],
|
||||||
|
|
||||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||||
@@ -667,7 +695,7 @@
|
|||||||
|
|
||||||
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
|
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
|
||||||
|
|
||||||
"react-router": ["react-router@7.5.3", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0", "turbo-stream": "2.4.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-3iUDM4/fZCQ89SXlDa+Ph3MevBrozBAI655OAfWQlTm9nBR0IKlrmNwFow5lPHttbwvITZfkeeeZFP6zt3F7pw=="],
|
"react-router": ["react-router@7.6.0", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-GGufuHIVCJDbnIAXP3P9Sxzq3UUsddG3rrI3ut1q6m0FI6vxVBF3JoPQ38+W/blslLH4a5Yutp8drkEpXoddGQ=="],
|
||||||
|
|
||||||
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
|
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
|
||||||
|
|
||||||
@@ -733,12 +761,14 @@
|
|||||||
|
|
||||||
"strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
"strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||||
|
|
||||||
"tailwind-merge": ["tailwind-merge@3.2.0", "", {}, "sha512-FQT/OVqCD+7edmmJpsgCsY820RTD5AkBryuG5IUqR5YQZSdj5xlH5nLgH7YPths7WsLPSpSBNneJdM8aS8aeFA=="],
|
"tailwind-merge": ["tailwind-merge@3.3.0", "", {}, "sha512-fyW/pEfcQSiigd5SNn0nApUOxx0zB/dm6UDU/rEwc2c3sX2smWUNbapHv+QRqLGVp9GWX3THIa7MUGPo+YkDzQ=="],
|
||||||
|
|
||||||
"tailwindcss": ["tailwindcss@4.1.5", "", {}, "sha512-nYtSPfWGDiWgCkwQG/m+aX83XCwf62sBgg3bIlNiiOcggnS1x3uVRDAuyelBFL+vJdOPPCGElxv9DjHJjRHiVA=="],
|
"tailwindcss": ["tailwindcss@4.1.6", "", {}, "sha512-j0cGLTreM6u4OWzBeLBpycK0WIh8w7kSwcUsQZoGLHZ7xDTdM69lN64AgoIEEwFi0tnhs4wSykUa5YWxAzgFYg=="],
|
||||||
|
|
||||||
"tapable": ["tapable@2.2.1", "", {}, "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ=="],
|
"tapable": ["tapable@2.2.1", "", {}, "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ=="],
|
||||||
|
|
||||||
|
"tar": ["tar@7.4.3", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.0.1", "mkdirp": "^3.0.1", "yallist": "^5.0.0" } }, "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw=="],
|
||||||
|
|
||||||
"tinyglobby": ["tinyglobby@0.2.13", "", { "dependencies": { "fdir": "^6.4.4", "picomatch": "^4.0.2" } }, "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw=="],
|
"tinyglobby": ["tinyglobby@0.2.13", "", { "dependencies": { "fdir": "^6.4.4", "picomatch": "^4.0.2" } }, "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw=="],
|
||||||
|
|
||||||
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
|
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
|
||||||
@@ -747,8 +777,6 @@
|
|||||||
|
|
||||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||||
|
|
||||||
"turbo-stream": ["turbo-stream@2.4.0", "", {}, "sha512-FHncC10WpBd2eOmGwpmQsWLDoK4cqsA/UT/GqNoaKOQnT8uzhtCbg3EoUDMvqpOSAI0S26mr0rkjzbOO6S3v1g=="],
|
|
||||||
|
|
||||||
"tw-animate-css": ["tw-animate-css@1.2.9", "", {}, "sha512-9O4k1at9pMQff9EAcCEuy1UNO43JmaPQvq+0lwza9Y0BQ6LB38NiMj+qHqjoQf40355MX+gs6wtlR6H9WsSXFg=="],
|
"tw-animate-css": ["tw-animate-css@1.2.9", "", {}, "sha512-9O4k1at9pMQff9EAcCEuy1UNO43JmaPQvq+0lwza9Y0BQ6LB38NiMj+qHqjoQf40355MX+gs6wtlR6H9WsSXFg=="],
|
||||||
|
|
||||||
"type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="],
|
"type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="],
|
||||||
@@ -757,7 +785,7 @@
|
|||||||
|
|
||||||
"undici": ["undici@6.21.2", "", {}, "sha512-uROZWze0R0itiAKVPsYhFov9LxrPMHLMEQFszeI2gCN6bnIIZ8twzBCJcN2LJrBBLfrP0t1FW0g+JmKVl8Vk1g=="],
|
"undici": ["undici@6.21.2", "", {}, "sha512-uROZWze0R0itiAKVPsYhFov9LxrPMHLMEQFszeI2gCN6bnIIZ8twzBCJcN2LJrBBLfrP0t1FW0g+JmKVl8Vk1g=="],
|
||||||
|
|
||||||
"undici-types": ["undici-types@6.19.8", "", {}, "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw=="],
|
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||||
|
|
||||||
"universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
|
"universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
|
||||||
|
|
||||||
@@ -793,7 +821,7 @@
|
|||||||
|
|
||||||
"wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
|
"wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
|
||||||
|
|
||||||
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
"yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
|
||||||
|
|
||||||
"zod": ["zod@3.24.4", "", {}, "sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg=="],
|
"zod": ["zod@3.24.4", "", {}, "sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg=="],
|
||||||
|
|
||||||
@@ -837,6 +865,8 @@
|
|||||||
|
|
||||||
"hosted-git-info/lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="],
|
"hosted-git-info/lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="],
|
||||||
|
|
||||||
|
"lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||||
|
|
||||||
"mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
"mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||||
|
|
||||||
"morgan/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
"morgan/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
||||||
|
|||||||
3895
frontend.txt
Normal file
3895
frontend.txt
Normal file
File diff suppressed because it is too large
Load Diff
30
package.json
30
package.json
@@ -10,41 +10,45 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^5.0.1",
|
"@hookform/resolvers": "^5.0.1",
|
||||||
|
"@radix-ui/react-aspect-ratio": "^1.1.6",
|
||||||
"@radix-ui/react-avatar": "^1.1.9",
|
"@radix-ui/react-avatar": "^1.1.9",
|
||||||
"@radix-ui/react-dialog": "^1.1.13",
|
"@radix-ui/react-dialog": "^1.1.13",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.14",
|
"@radix-ui/react-dropdown-menu": "^2.1.14",
|
||||||
"@radix-ui/react-label": "^2.1.6",
|
"@radix-ui/react-label": "^2.1.6",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.8",
|
"@radix-ui/react-scroll-area": "^1.2.8",
|
||||||
|
"@radix-ui/react-select": "^2.2.4",
|
||||||
"@radix-ui/react-separator": "^1.1.6",
|
"@radix-ui/react-separator": "^1.1.6",
|
||||||
"@radix-ui/react-slot": "^1.2.2",
|
"@radix-ui/react-slot": "^1.2.2",
|
||||||
"@radix-ui/react-tabs": "^1.1.11",
|
"@radix-ui/react-tabs": "^1.1.11",
|
||||||
"@radix-ui/react-tooltip": "^1.2.6",
|
"@radix-ui/react-tooltip": "^1.2.6",
|
||||||
"@react-router/node": "^7.5.3",
|
"@react-router/node": "^7.6.0",
|
||||||
"@react-router/serve": "^7.5.3",
|
"@react-router/serve": "^7.6.0",
|
||||||
|
"@tanstack/react-query": "^5.76.1",
|
||||||
|
"@yornaath/batshit": "^0.10.1",
|
||||||
"axios": "^1.9.0",
|
"axios": "^1.9.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"immer": "^10.1.1",
|
"immer": "^10.1.1",
|
||||||
"isbot": "^5.1.27",
|
"isbot": "^5.1.28",
|
||||||
"lucide-react": "^0.508.0",
|
"lucide-react": "^0.510.0",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-hook-form": "^7.56.3",
|
"react-hook-form": "^7.56.3",
|
||||||
"react-router": "^7.5.3",
|
"react-router": "^7.6.0",
|
||||||
"tailwind-merge": "^3.2.0",
|
"tailwind-merge": "^3.3.0",
|
||||||
"zod": "^3.24.4",
|
"zod": "^3.24.4",
|
||||||
"zustand": "^5.0.4"
|
"zustand": "^5.0.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@react-router/dev": "^7.5.3",
|
"@react-router/dev": "^7.6.0",
|
||||||
"@tailwindcss/vite": "^4.1.4",
|
"@tailwindcss/vite": "^4.1.6",
|
||||||
"@types/node": "^20",
|
"@types/node": "^22.15.18",
|
||||||
"@types/react": "^19.1.2",
|
"@types/react": "^19.1.4",
|
||||||
"@types/react-dom": "^19.1.2",
|
"@types/react-dom": "^19.1.5",
|
||||||
"tailwindcss": "^4.1.4",
|
"tailwindcss": "^4.1.6",
|
||||||
"tw-animate-css": "^1.2.9",
|
"tw-animate-css": "^1.2.9",
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.8.3",
|
||||||
"vite": "^6.3.3",
|
"vite": "^6.3.5",
|
||||||
"vite-tsconfig-paths": "^5.1.4"
|
"vite-tsconfig-paths": "^5.1.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user