.
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -4,3 +4,4 @@
|
||||
# React Router
|
||||
/.react-router/
|
||||
/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";
|
||||
}
|
||||
|
||||
: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 {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
@@ -28,6 +118,7 @@
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
@@ -44,78 +135,37 @@
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
--font-sans: var(--font-sans);
|
||||
--font-mono: var(--font-mono);
|
||||
--font-serif: var(--font-serif);
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
|
||||
--shadow-2xs: var(--shadow-2xs);
|
||||
--shadow-xs: var(--shadow-xs);
|
||||
--shadow-sm: var(--shadow-sm);
|
||||
--shadow: var(--shadow);
|
||||
--shadow-md: var(--shadow-md);
|
||||
--shadow-lg: var(--shadow-lg);
|
||||
--shadow-xl: var(--shadow-xl);
|
||||
--shadow-2xl: var(--shadow-2xl);
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
@@ -131,6 +181,7 @@
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
|
||||
/* Hide scrollbar for Chrome, Safari and Opera */
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
|
||||
@@ -1,72 +1,41 @@
|
||||
import { Headphones, Mic, Settings } from "lucide-react";
|
||||
import React from "react";
|
||||
import { NavLink } from "react-router";
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { cn, getFirstLetters } from "~/lib/utils";
|
||||
import { useServerListStore } from "~/store/server-list";
|
||||
import { useUserStore } from "~/store/user";
|
||||
import { CreateServerButton } from "./create-server";
|
||||
import Discord from "./icons/Discord";
|
||||
import { OnlineStatus } from "./online-status";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";
|
||||
import { Button } from "./ui/button";
|
||||
import { useMatches } from "react-router";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useServerListStore } from "~/stores/server-list-store";
|
||||
import { CreateServerButton } from "./custom-ui/create-server-button";
|
||||
import { HomeButton } from "./custom-ui/home-button";
|
||||
import { ServerButton } from "./custom-ui/server-button";
|
||||
import UserStatus from "./custom-ui/user-status";
|
||||
import { ScrollArea } from "./ui/scroll-area";
|
||||
import { Separator } from "./ui/separator";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip";
|
||||
|
||||
interface AppLayoutProps {
|
||||
list: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function AppLayout({ list, children }: AppLayoutProps) {
|
||||
let user = useUserStore(state => state.user!)
|
||||
let servers = useServerListStore(useShallow((state) => Array.from(state.servers.values())))
|
||||
export default function AppLayout({ children }: AppLayoutProps) {
|
||||
let servers = useServerListStore(useShallow((state) => Object.values(state.servers)))
|
||||
|
||||
const matches = useMatches();
|
||||
|
||||
let list = React.useMemo(() => {
|
||||
return matches.map(match => (match.handle as {
|
||||
listComponent?: React.ReactNode
|
||||
})?.listComponent).reverse().find(component => !!component)
|
||||
}, [matches])
|
||||
|
||||
return (
|
||||
<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">
|
||||
<ScrollArea className="h-full px-1" scrollbarSize="none">
|
||||
<aside className="flex flex-col gap-4 p-2 h-full">
|
||||
<NavLink to={`/app/@me`} className={({ isActive }) =>
|
||||
cn(
|
||||
"rounded-xl",
|
||||
isActive ? "bg-primary" : "bg-accent",
|
||||
)
|
||||
}>
|
||||
<Discord className="size-8 m-2" />
|
||||
</NavLink>
|
||||
<HomeButton />
|
||||
<Separator />
|
||||
{
|
||||
servers.map((server, _) =>
|
||||
<React.Fragment key={server.id}>
|
||||
<TooltipProvider>
|
||||
<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>
|
||||
<ServerButton server={server} />
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
@@ -80,37 +49,7 @@ export default function AppLayout({ list, children }: AppLayoutProps) {
|
||||
</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="outline-1 p-2 h-full rounded-xl">
|
||||
<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>
|
||||
<UserStatus />
|
||||
</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 (
|
||||
<div className="relative">
|
||||
<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 === "dnd" && <CircleMinus className="size-full stroke-red-400 stroke-3" />}
|
||||
{status === "idle" && <Moon className="size-full stroke-amber-400 fill-amber-400" />}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { EyeIcon, EyeOffIcon } from "lucide-react"
|
||||
import React from "react"
|
||||
import { Button } from "./ui/button"
|
||||
import { Input } from "./ui/input"
|
||||
import { Button } from "../ui/button"
|
||||
import { Input } from "../ui/input"
|
||||
|
||||
export function PasswordInput(props: React.ComponentProps<"input">) {
|
||||
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 (
|
||||
<div
|
||||
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",
|
||||
"focus-within:ring-2 focus-within:ring-ring focus-within:border-ring",
|
||||
"max-h-96 w-full overflow-y-auto min-h-6",
|
||||
wrapperClassName
|
||||
)}
|
||||
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";
|
||||
|
||||
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;
|
||||
@@ -1,47 +1,42 @@
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect } from 'react';
|
||||
import { useGatewayWebSocketStore } from '~/store/gateway-websocket';
|
||||
import { useTokenStore } from '~/store/token';
|
||||
import { ConnectionState } from '~/lib/websocket/gateway/types';
|
||||
import { useGatewayStore } from '~/stores/gateway-store';
|
||||
import { useTokenStore } from '~/stores/token-store';
|
||||
|
||||
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) =>
|
||||
state.token,
|
||||
);
|
||||
|
||||
const { setQueryClient } = useGatewayStore();
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
useEffect(() => {
|
||||
console.debug(`WS Manager: Status (${wsStatus})`);
|
||||
setQueryClient(queryClient);
|
||||
}, [queryClient])
|
||||
|
||||
useEffect(() => {
|
||||
const { status, connect, disconnect } = useGatewayStore.getState();
|
||||
|
||||
if (!!token) {
|
||||
// Connect if we should be connected and are currently idle, disconnected, or errored out
|
||||
if (wsStatus === 'IDLE' || wsStatus === 'DISCONNECTED' || wsStatus === 'ERROR') {
|
||||
console.log("WS Manager: Conditions met. Calling connect...");
|
||||
// Pass the stable token getter function reference
|
||||
connectWebSocket(() => token);
|
||||
}
|
||||
connect(token);
|
||||
} else {
|
||||
// Disconnect if we shouldn't be connected and are currently in any connected/connecting state
|
||||
if (wsStatus !== 'IDLE' && wsStatus !== 'DISCONNECTED') {
|
||||
console.log("WS Manager: Conditions no longer met. Calling disconnect...");
|
||||
disconnectWebSocket(true); // Intentional disconnect
|
||||
if (status === ConnectionState.CONNECTED) {
|
||||
disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
// The disconnect logic for component unmount (e.g., user logs out entirely)
|
||||
return () => {
|
||||
// Check status on unmount to avoid disconnecting if already idle/disconnected
|
||||
const currentStatus = useGatewayWebSocketStore.getState().status;
|
||||
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
|
||||
if (status === ConnectionState.CONNECTED) {
|
||||
disconnect();
|
||||
}
|
||||
};
|
||||
// Dependencies: connect/disconnect actions, auth status, route location
|
||||
}, [connectWebSocket, disconnectWebSocket]);
|
||||
}, [token]);
|
||||
|
||||
// This component doesn't render anything itself
|
||||
return null;
|
||||
return (
|
||||
<>
|
||||
{null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,185 +1,52 @@
|
||||
// ~/components/webrtc-connection-manager.tsx
|
||||
import { useEffect, useRef } from 'react'; // Removed useState
|
||||
import { useActiveVoiceChannelStore } from '~/store/active-voice-channel';
|
||||
import { useGatewayWebSocketStore } from '~/store/gateway-websocket';
|
||||
import { useWebRTCStore } from '~/store/webrtc'; // Ensure WebRTCStatus is exported
|
||||
import { useEffect, useRef } from "react";
|
||||
import { ConnectionState } from "~/lib/websocket/voice/types";
|
||||
import { useGatewayStore } from "~/stores/gateway-store";
|
||||
import { useVoiceStateStore } from "~/stores/voice-state-store";
|
||||
import { useWebRTCStore } from "~/stores/webrtc-store";
|
||||
|
||||
export function WebRTCConnectionManager() {
|
||||
console.log('WebRTC Manager: Mounting component.');
|
||||
const gateway = useGatewayStore();
|
||||
const voiceState = useVoiceStateStore();
|
||||
const webrtc = useWebRTCStore();
|
||||
|
||||
const {
|
||||
serverId: activeServerId,
|
||||
channelId: activeChannelId,
|
||||
isVoiceActive,
|
||||
} = useActiveVoiceChannelStore(state => ({
|
||||
serverId: state.serverId,
|
||||
channelId: state.channelId,
|
||||
isVoiceActive: state.isVoiceActive,
|
||||
}));
|
||||
const remoteStream = useWebRTCStore(state => state.remoteStream);
|
||||
const audioRef = useRef<HTMLAudioElement>(null)
|
||||
|
||||
const gatewayStatus = useGatewayWebSocketStore((state) => state.status);
|
||||
|
||||
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);
|
||||
if (audioRef.current) {
|
||||
audioRef.current.srcObject = remoteStream
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
console.log('WebRTC Manager: Effect triggered', {
|
||||
activeServerId,
|
||||
activeChannelId,
|
||||
isVoiceActive,
|
||||
gatewayStatus,
|
||||
webRTCStatus,
|
||||
rtcCurrentChannelId,
|
||||
operationLock: operationLockRef.current,
|
||||
const unsubscribe = gateway.onVoiceServerUpdate(async (event) => {
|
||||
await webrtc.connect(event.token);
|
||||
voiceState.joinVoiceChannel(event.serverId, event.channelId);
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
noiseSuppression: false,
|
||||
},
|
||||
video: false
|
||||
});
|
||||
|
||||
const manageWebRTCConnection = async () => {
|
||||
if (operationLockRef.current) {
|
||||
console.debug('WebRTC Manager: Operation in progress, skipping.');
|
||||
return;
|
||||
}
|
||||
webrtc.createOffer(stream);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
console.log('WebRTC Manager: Unmounting component.');
|
||||
// Ensure we attempt to leave if connection is active
|
||||
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;
|
||||
}
|
||||
voiceState.leaveVoiceChannel();
|
||||
unsubscribe();
|
||||
};
|
||||
}, []); // 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 { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "~/components/ui/dialog";
|
||||
import file from "~/lib/api/client/file";
|
||||
import server from "~/lib/api/client/server";
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "./ui/form";
|
||||
import { IconUploadField } from "./ui/icon-upload-field";
|
||||
import { Input } from "./ui/input";
|
||||
import { ModalType, useModalStore } 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 { IconUploadField } from "../ui/icon-upload-field";
|
||||
import { Input } from "../ui/input";
|
||||
|
||||
const schema = z.object({
|
||||
name: z.string().min(1).max(32),
|
||||
icon: z.instanceof(File).optional(),
|
||||
});
|
||||
|
||||
export function CreateServerButton() {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
export default function CreateServerModal() {
|
||||
const { type, isOpen, onClose } = useModalStore();
|
||||
|
||||
const isModalOpen = type === ModalType.CREATE_SERVER && isOpen
|
||||
|
||||
let form = useForm<z.infer<typeof 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()
|
||||
}
|
||||
onClose()
|
||||
}
|
||||
|
||||
async function onSubmit(values: z.infer<typeof schema>) {
|
||||
const response = await server.create(values)
|
||||
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="secondary" size="none">
|
||||
<CirclePlus className="size-8 m-2" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<Dialog open={isModalOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<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"
|
||||
|
||||
type Theme = "dark" | "light" | "system"
|
||||
export type Theme = "dark" | "light" | "system"
|
||||
|
||||
type ThemeProviderProps = {
|
||||
children: React.ReactNode
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
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 {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuTrigger
|
||||
} from "~/components/ui/dropdown-menu"
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { setTheme } = useTheme()
|
||||
const { theme, setTheme } = useTheme()
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
@@ -21,16 +22,18 @@ export function ThemeToggle() {
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setTheme("light")}>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuRadioGroup value={theme} onValueChange={(value) => setTheme(value as Theme)}>
|
||||
<DropdownMenuRadioItem value="light">
|
||||
Light
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("dark")}>
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="dark">
|
||||
Dark
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("system")}>
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="system">
|
||||
System
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</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,
|
||||
children,
|
||||
scrollbarSize,
|
||||
viewportRef,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root> & {
|
||||
scrollbarSize?: "default" | "narrow" | "none"
|
||||
scrollbarSize?: "default" | "narrow" | "none",
|
||||
viewportRef?: React.Ref<HTMLDivElement>
|
||||
}) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
@@ -18,6 +20,7 @@ function ScrollArea({
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
ref={viewportRef}
|
||||
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"
|
||||
>
|
||||
|
||||
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 type { User } from "../types"
|
||||
import type { FullUser } from "../types"
|
||||
|
||||
interface RegisterRequest {
|
||||
email: string
|
||||
@@ -14,7 +14,7 @@ interface LoginRequest {
|
||||
}
|
||||
|
||||
interface LoginResponse {
|
||||
user: User
|
||||
user: FullUser
|
||||
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 type { Server } from "../types"
|
||||
import type { ChannelId, ChannelType, Server, ServerChannel, ServerId, ServerInvite } from "../types"
|
||||
|
||||
interface CreateServerRequest {
|
||||
name: string
|
||||
icon?: File
|
||||
iconId?: string
|
||||
}
|
||||
|
||||
export async function create(request: CreateServerRequest) {
|
||||
const response = await axios.postForm("/servers", request)
|
||||
|
||||
return response.data as Server
|
||||
interface CreateServerChannelRequest {
|
||||
name: string
|
||||
type: ChannelType
|
||||
}
|
||||
|
||||
export async function list() {
|
||||
@@ -18,7 +17,69 @@ export async function list() {
|
||||
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 {
|
||||
create,
|
||||
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 type { RecipientChannel, User } from "../types"
|
||||
import type { FullUser, PartialUser, RecipientChannel, UserId, Uuid } from "../types"
|
||||
|
||||
export async function 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() {
|
||||
@@ -13,7 +19,20 @@ export async function channels() {
|
||||
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 {
|
||||
me,
|
||||
channels
|
||||
channels,
|
||||
getUser,
|
||||
patchUser
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import axios from "axios"
|
||||
import { useTokenStore } from "~/store/token"
|
||||
import { useTokenStore } from "~/stores/token-store"
|
||||
import { API_URL } from "../consts"
|
||||
|
||||
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 interface User {
|
||||
id: Uuid
|
||||
export type UserId = Uuid
|
||||
export type ServerId = Uuid
|
||||
export type ChannelId = Uuid
|
||||
export type MessageId = Uuid
|
||||
|
||||
export interface FullUser {
|
||||
id: UserId
|
||||
avatarUrl?: string
|
||||
username: string
|
||||
displayName?: string
|
||||
@@ -12,22 +50,88 @@ export interface User {
|
||||
}
|
||||
|
||||
export interface Server {
|
||||
id: Uuid
|
||||
id: ServerId
|
||||
name: string
|
||||
icon_url?: string
|
||||
owner: Uuid
|
||||
iconUrl?: string
|
||||
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 {
|
||||
id: Uuid
|
||||
id: ChannelId
|
||||
name: string
|
||||
type: string
|
||||
lastMessageId?: Uuid
|
||||
type: ChannelType
|
||||
lastMessageId?: MessageId
|
||||
recipients: PartialUser[]
|
||||
}
|
||||
|
||||
export interface PartialUser {
|
||||
id: Uuid
|
||||
id: ChannelId
|
||||
username: string
|
||||
displayName?: string
|
||||
avatarUrl?: string,
|
||||
|
||||
@@ -12,3 +12,32 @@ export function getFirstLetters(str: string, n: number): string {
|
||||
.map(word => word[0] || '')
|
||||
.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"),
|
||||
]),
|
||||
...prefix("/app", [
|
||||
layout("routes/app/providers.tsx", [
|
||||
route("/settings", "routes/app/settings.tsx"),
|
||||
layout("routes/app/layout.tsx", [
|
||||
index("routes/app/index.tsx"),
|
||||
route("/invite/:inviteCode", "routes/app/invite.tsx"),
|
||||
...prefix("/@me", [
|
||||
layout("routes/app/me/layout.tsx", [
|
||||
index("routes/app/me/index.tsx"),
|
||||
@@ -18,9 +21,9 @@ export default [
|
||||
...prefix("/server/:serverId", [
|
||||
layout("routes/app/server/layout.tsx", [
|
||||
index("routes/app/server/index.tsx"),
|
||||
route("/channels/:channelId", "routes/app/server/channel.tsx"),
|
||||
])
|
||||
route("/:channelId", "routes/app/server/channel.tsx"),
|
||||
])
|
||||
])
|
||||
])]),
|
||||
]),
|
||||
] satisfies RouteConfig;
|
||||
|
||||
@@ -1,58 +1,9 @@
|
||||
import ChannelListItem from "~/components/channel-list-item";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "~/components/ui/dropdown-menu";
|
||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||
import { redirect } from "react-router";
|
||||
|
||||
export const handle = {
|
||||
listComponent: (
|
||||
<>
|
||||
<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 function clientLoader() {
|
||||
return redirect("/app/@me")
|
||||
}
|
||||
|
||||
export default function Index() {
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
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, useMatches } from "react-router";
|
||||
import { Outlet, redirect } from "react-router";
|
||||
import AppLayout from "~/components/app-layout";
|
||||
import { GatewayWebSocketConnectionManager } from "~/components/manager/gateway-websocket-connection-manager";
|
||||
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";
|
||||
import { useServerListStore } from "~/stores/server-list-store";
|
||||
|
||||
export async function clientLoader() {
|
||||
let { user, setUser } = useUserStore.getState()
|
||||
const { servers, addServers } = useServerListStore.getState()
|
||||
|
||||
try {
|
||||
if (!user) {
|
||||
const user = await import("~/lib/api/client/user").then(m => m.default.me())
|
||||
setUser(user)
|
||||
if (!servers || Object.values(servers).length === 0) {
|
||||
const newServers = await import("~/lib/api/client/server").then(m => m.default.list())
|
||||
addServers(newServers)
|
||||
}
|
||||
|
||||
const servers = await import("~/lib/api/client/server").then(m => m.default.list())
|
||||
useServerListStore.getState().setServers(servers)
|
||||
} catch (error) {
|
||||
return redirect("/login")
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
export default function Layout() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<GatewayWebSocketConnectionManager />
|
||||
<WebRTCConnectionManager />
|
||||
{/* <GlobalWebRTCAudioPlayer /> */}
|
||||
<AppLayout list={list}>
|
||||
<AppLayout >
|
||||
<Outlet />
|
||||
</AppLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,69 +1,37 @@
|
||||
import type { Route } from ".react-router/types/app/routes/app/me/+types/channel";
|
||||
import { useRef } from "react";
|
||||
import TextBox from "~/components/text-box";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import type { Uuid } from "~/lib/api/types";
|
||||
import { Check } from "lucide-react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import ChannelArea from "~/components/channel-area";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { usePrivateChannelsStore } from "~/stores/private-channels-store";
|
||||
import { useUsersStore } from "~/stores/users-store";
|
||||
|
||||
export default function Channel({
|
||||
params
|
||||
}: Route.ComponentProps) {
|
||||
let channelId = params.channelId
|
||||
const channelId = params.channelId
|
||||
const currentUserId = useUsersStore(state => state.currentUserId)
|
||||
|
||||
const audioRef = useRef<HTMLAudioElement>(null)
|
||||
let peerConnection: RTCPeerConnection | null = null
|
||||
const nativeChannel = usePrivateChannelsStore(useShallow(state => state.channels[channelId]))
|
||||
const recipients = nativeChannel.recipients.filter(recipient => recipient.id !== currentUserId)
|
||||
|
||||
async function testSdp(channelId: Uuid) {
|
||||
const stream = await navigator.mediaDevices.getUserMedia(
|
||||
{
|
||||
audio: true,
|
||||
}
|
||||
);
|
||||
const renderSystemBadge = recipients.some(recipient => recipient.system) && recipients.length === 1
|
||||
|
||||
const config = {
|
||||
iceServers: [],
|
||||
};
|
||||
peerConnection = new RTCPeerConnection(config);
|
||||
stream.getTracks().forEach((track) => peerConnection!.addTrack(track, stream));
|
||||
|
||||
peerConnection.addEventListener("track", (event) => {
|
||||
console.log(event);
|
||||
audioRef.current!.srcObject = event.streams[0];
|
||||
});
|
||||
|
||||
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);
|
||||
const channel = {
|
||||
...nativeChannel,
|
||||
name: <>
|
||||
<div className="flex items-center gap-2">
|
||||
<div>
|
||||
{recipients.map(recipient => recipient.displayName || recipient.username).join(", ")}
|
||||
</div>
|
||||
{renderSystemBadge && <Badge variant="default"> <Check />System</Badge>}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<audio autoPlay ref={audioRef} />
|
||||
<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>
|
||||
<ChannelArea channel={channel} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
import TextBox from "~/components/text-box";
|
||||
|
||||
export default function Index() {
|
||||
return (
|
||||
<>
|
||||
<div className="h-full">
|
||||
{/* <div className="h-full">
|
||||
<div className="size-full relative">
|
||||
<div className="absolute bottom-0 w-full max-h-1/2">
|
||||
<div className="p-2">
|
||||
@@ -17,7 +16,7 @@ export default function Index() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div> */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
import React from "react";
|
||||
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 { usePrivateChannelsStore } from "~/store/private-channels";
|
||||
import { usePrivateChannelsStore } from "~/stores/private-channels-store";
|
||||
|
||||
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())
|
||||
setChannels(channels)
|
||||
}
|
||||
}
|
||||
|
||||
function ListComponent() {
|
||||
const channels = usePrivateChannelsStore(state => state.channels)
|
||||
const channels = usePrivateChannelsStore(useShallow(state => Object.values(state.channels)))
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
@@ -26,9 +30,9 @@ function ListComponent() {
|
||||
<ScrollArea className="overflow-auto" scrollbarSize="narrow">
|
||||
<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, _) => (
|
||||
<>
|
||||
<React.Fragment key={channel.id}>
|
||||
<PrivateChannelListItem channel={channel} />
|
||||
</>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</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 (
|
||||
<>
|
||||
<div className="h-full">
|
||||
<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>
|
||||
<ChannelArea channel={channel} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
import TextBox from "~/components/text-box";
|
||||
|
||||
export default function Index() {
|
||||
return (
|
||||
<>
|
||||
<div className="h-full">
|
||||
{/* <div className="h-full">
|
||||
<div className="size-full relative">
|
||||
<div className="absolute bottom-0 w-full max-h-1/2">
|
||||
<div className="p-2">
|
||||
@@ -17,7 +16,7 @@ export default function Index() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div> */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,38 +1,85 @@
|
||||
import { Outlet, useParams } from "react-router";
|
||||
import ChannelListItem from "~/components/channel-list-item";
|
||||
import React from "react";
|
||||
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 { usePrivateChannelsStore } from "~/store/private-channels";
|
||||
import { useServerListStore } from "~/store/server-list";
|
||||
import type { ServerId } from "~/lib/api/types";
|
||||
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() {
|
||||
const {channels, setChannels} = usePrivateChannelsStore.getState()
|
||||
export async function clientLoader({
|
||||
params: { serverId }
|
||||
}: Route.ClientLoaderArgs) {
|
||||
const { channels, addChannels, addServer } = useServerChannelsStore.getState()
|
||||
|
||||
if (!channels || channels.length === 0) {
|
||||
const channels = await import("~/lib/api/client/user").then(m => m.default.channels())
|
||||
setChannels(channels)
|
||||
const server = useServerListStore.getState().servers[serverId as ServerId] || undefined
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldRevalidate(
|
||||
arg: ShouldRevalidateFunctionArgs
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
function ListComponent() {
|
||||
const channels = []
|
||||
const serverId = useParams<{ serverId: ServerId }>().serverId!
|
||||
const currentUserId = useUsersStore(state => state.currentUserId)
|
||||
const onOpen = useModalStore(state => state.onOpen)
|
||||
|
||||
const serverId = useParams<{ serverId: string }>().serverId!
|
||||
const server = useServerListStore(state => state.servers.get(serverId))
|
||||
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 (
|
||||
<div className="h-full flex flex-col">
|
||||
<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}
|
||||
</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>
|
||||
|
||||
<ScrollArea className="overflow-auto" scrollbarSize="narrow">
|
||||
<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, _) => (
|
||||
<>
|
||||
<ChannelListItem channel={channel} />
|
||||
</>
|
||||
<React.Fragment key={channel.id}>
|
||||
<ServerChannelListItem channel={channel} />
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
@@ -44,7 +91,17 @@ export const handle = {
|
||||
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 (
|
||||
<>
|
||||
<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 { Link, redirect, useNavigate } from "react-router";
|
||||
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 { Button, buttonVariants } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
@@ -17,8 +18,8 @@ import {
|
||||
} from "~/components/ui/form";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import auth from "~/lib/api/client/auth";
|
||||
import { useTokenStore } from "~/store/token";
|
||||
import { useUserStore } from "~/store/user";
|
||||
import { useTokenStore } from "~/stores/token-store";
|
||||
import { useUsersStore } from "~/stores/users-store";
|
||||
|
||||
const schema = z.object({
|
||||
username: z.string(),
|
||||
@@ -46,7 +47,12 @@ export async function clientLoader() {
|
||||
export default function Login() {
|
||||
let navigate = useNavigate()
|
||||
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>>({
|
||||
resolver: zodResolver(schema),
|
||||
@@ -56,7 +62,8 @@ export default function Login() {
|
||||
const response = await auth.login(values)
|
||||
|
||||
setToken(response.token)
|
||||
setUser(response.user)
|
||||
setCurrentUserId(response.user.id)
|
||||
addUser(response.user)
|
||||
|
||||
navigate("/app")
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
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 { Button, buttonVariants } from "~/components/ui/button";
|
||||
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",
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.0.1",
|
||||
"@radix-ui/react-aspect-ratio": "^1.1.6",
|
||||
"@radix-ui/react-avatar": "^1.1.9",
|
||||
"@radix-ui/react-dialog": "^1.1.13",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.14",
|
||||
"@radix-ui/react-label": "^2.1.6",
|
||||
"@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-slot": "^1.2.2",
|
||||
"@radix-ui/react-tabs": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.2.6",
|
||||
"@react-router/node": "^7.5.3",
|
||||
"@react-router/serve": "^7.5.3",
|
||||
"@react-router/node": "^7.6.0",
|
||||
"@react-router/serve": "^7.6.0",
|
||||
"@tanstack/react-query": "^5.76.1",
|
||||
"@yornaath/batshit": "^0.10.1",
|
||||
"axios": "^1.9.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"immer": "^10.1.1",
|
||||
"isbot": "^5.1.27",
|
||||
"lucide-react": "^0.508.0",
|
||||
"isbot": "^5.1.28",
|
||||
"lucide-react": "^0.510.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-hook-form": "^7.56.3",
|
||||
"react-router": "^7.5.3",
|
||||
"tailwind-merge": "^3.2.0",
|
||||
"react-router": "^7.6.0",
|
||||
"tailwind-merge": "^3.3.0",
|
||||
"zod": "^3.24.4",
|
||||
"zustand": "^5.0.4",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@react-router/dev": "^7.5.3",
|
||||
"@tailwindcss/vite": "^4.1.4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19.1.2",
|
||||
"@types/react-dom": "^19.1.2",
|
||||
"tailwindcss": "^4.1.4",
|
||||
"@react-router/dev": "^7.6.0",
|
||||
"@tailwindcss/vite": "^4.1.6",
|
||||
"@types/node": "^22.15.18",
|
||||
"@types/react": "^19.1.4",
|
||||
"@types/react-dom": "^19.1.5",
|
||||
"tailwindcss": "^4.1.6",
|
||||
"tw-animate-css": "^1.2.9",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^6.3.3",
|
||||
"vite": "^6.3.5",
|
||||
"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/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/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-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-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-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-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-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-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=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
@@ -307,43 +319,51 @@
|
||||
|
||||
"@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/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=="],
|
||||
|
||||
@@ -387,6 +407,8 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"isbot": ["isbot@5.1.27", "", {}, "sha512-V3W56Hnztt4Wdh3VUlAMbdNicX/tOM38eChW3a2ixP6KEBJAeehxzYzTD59JrU5NCTgBZwRt9lRWr8D7eMZVYQ=="],
|
||||
"isbot": ["isbot@5.1.28", "", {}, "sha512-qrOp4g3xj8YNse4biorv6O5ZShwsJM0trsoda4y7j/Su7ZtTTfVXFzbKkpgcSoDrHS8FcTuUwcU04YimZlZOxw=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -593,6 +617,10 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"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-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=="],
|
||||
|
||||
@@ -733,12 +761,14 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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-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=="],
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -837,6 +865,8 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"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": {
|
||||
"@hookform/resolvers": "^5.0.1",
|
||||
"@radix-ui/react-aspect-ratio": "^1.1.6",
|
||||
"@radix-ui/react-avatar": "^1.1.9",
|
||||
"@radix-ui/react-dialog": "^1.1.13",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.14",
|
||||
"@radix-ui/react-label": "^2.1.6",
|
||||
"@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-slot": "^1.2.2",
|
||||
"@radix-ui/react-tabs": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.2.6",
|
||||
"@react-router/node": "^7.5.3",
|
||||
"@react-router/serve": "^7.5.3",
|
||||
"@react-router/node": "^7.6.0",
|
||||
"@react-router/serve": "^7.6.0",
|
||||
"@tanstack/react-query": "^5.76.1",
|
||||
"@yornaath/batshit": "^0.10.1",
|
||||
"axios": "^1.9.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"immer": "^10.1.1",
|
||||
"isbot": "^5.1.27",
|
||||
"lucide-react": "^0.508.0",
|
||||
"isbot": "^5.1.28",
|
||||
"lucide-react": "^0.510.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-hook-form": "^7.56.3",
|
||||
"react-router": "^7.5.3",
|
||||
"tailwind-merge": "^3.2.0",
|
||||
"react-router": "^7.6.0",
|
||||
"tailwind-merge": "^3.3.0",
|
||||
"zod": "^3.24.4",
|
||||
"zustand": "^5.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@react-router/dev": "^7.5.3",
|
||||
"@tailwindcss/vite": "^4.1.4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19.1.2",
|
||||
"@types/react-dom": "^19.1.2",
|
||||
"tailwindcss": "^4.1.4",
|
||||
"@react-router/dev": "^7.6.0",
|
||||
"@tailwindcss/vite": "^4.1.6",
|
||||
"@types/node": "^22.15.18",
|
||||
"@types/react": "^19.1.4",
|
||||
"@types/react-dom": "^19.1.5",
|
||||
"tailwindcss": "^4.1.6",
|
||||
"tw-animate-css": "^1.2.9",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^6.3.3",
|
||||
"vite": "^6.3.5",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user