This commit is contained in:
2025-05-15 05:20:01 +03:00
parent 623521f3b4
commit 21a05dd202
70 changed files with 4663 additions and 161 deletions

View File

@@ -1,15 +1,146 @@
@import "tailwindcss"; @import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme { @theme {
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif, --font-sans: "Inter", ui-sans-serif, system-ui, sans-serif,
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
} }
html, @theme inline {
body { --radius-sm: calc(var(--radius) - 4px);
@apply bg-white dark:bg-gray-950; --radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
@media (prefers-color-scheme: dark) { :root {
color-scheme: dark; --radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
button,
[role="button"] {
cursor: pointer;
} }
} }
@layer utilities {
/* Hide scrollbar for Chrome, Safari and Opera */
.no-scrollbar::-webkit-scrollbar {
display: none;
}
/* Hide scrollbar for IE, Edge and Firefox */
.no-scrollbar {
/* IE and Edge */
-ms-overflow-style: none;
/* Firefox */
scrollbar-width: none;
}
}

View File

@@ -0,0 +1,122 @@
import { Headphones, Mic, Settings } from "lucide-react";
import React from "react";
import { NavLink } from "react-router";
import { useShallow } from 'zustand/react/shallow';
import { cn, getFirstLetters } from "~/lib/utils";
import { useServerListStore } from "~/store/server-list";
import { useUserStore } from "~/store/user";
import { CreateServerButton } from "./create-server";
import Discord from "./icons/Discord";
import { OnlineStatus } from "./online-status";
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";
import { Button } from "./ui/button";
import { ScrollArea } from "./ui/scroll-area";
import { Separator } from "./ui/separator";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip";
interface AppLayoutProps {
list: React.ReactNode;
children: React.ReactNode;
}
export default function AppLayout({ list, children }: AppLayoutProps) {
let user = useUserStore(state => state.user!)
let servers = useServerListStore(useShallow((state) => Array.from(state.servers.values())))
return (
<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="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>
<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>
</React.Fragment>
)
}
{servers.length > 0 && <Separator />}
<CreateServerButton />
</aside>
</ScrollArea>
</div>
<div className="col-start-2 row-start-1 col-span-1 row-span-1 overflow-hidden">
{list}
</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>
</div>
</div>
<div className="grow">
{children}
</div>
</div>
)
}

View File

@@ -0,0 +1,45 @@
import { ChevronDown, Hash, Volume2 } from "lucide-react"
import { Button } from "./ui/button"
interface Channel {
id: string
name: string
type: "text" | "voice" | "category"
}
interface ChannelListItemProps {
channel: Channel
}
export default function ChannelListItem({ channel }: ChannelListItemProps) {
if (channel.type === "category") {
return (
<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>
</>
)
}

View File

@@ -0,0 +1,113 @@
import { CirclePlus } from "lucide-react";
import React from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { Button } from "~/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "~/components/ui/dialog";
import server from "~/lib/api/client/server";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "./ui/form";
import { IconUploadField } from "./ui/icon-upload-field";
import { Input } from "./ui/input";
const schema = z.object({
name: z.string().min(1).max(32),
icon: z.instanceof(File).optional(),
});
export function CreateServerButton() {
const [open, setOpen] = React.useState(false);
let form = useForm<z.infer<typeof schema>>({
resolver: zodResolver(schema),
})
function onOpenChange(openState: boolean) {
setOpen(openState)
if (!openState) {
form.reset()
}
}
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>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Create server</DialogTitle>
<DialogDescription>
Give your server a name and choose a server icon.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="icon"
render={({ field, fieldState }) => (
<FormItem>
<FormLabel required={!schema.shape.icon.isOptional()}>Icon</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="name"
render={({ field }) => (
<FormItem>
<FormLabel required={!schema.shape.name.isOptional()}>Name</FormLabel>
<FormControl>
<Input {...field} />
</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 ? "Creating..." : "Create"}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,51 @@
// ~/components/global-audio-player.tsx
import { useEffect, useRef } from 'react';
import { useWebRTCStore } from '~/store/webrtc';
export function GlobalWebRTCAudioPlayer() {
const audioRef = useRef<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
/>
);
}

View File

@@ -0,0 +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>;
export default Discord;

View File

@@ -0,0 +1,47 @@
import { useEffect } from 'react';
import { useGatewayWebSocketStore } from '~/store/gateway-websocket';
import { useTokenStore } from '~/store/token';
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,
);
useEffect(() => {
console.debug(`WS Manager: Status (${wsStatus})`);
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);
}
} 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
}
}
// 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
}
};
// Dependencies: connect/disconnect actions, auth status, route location
}, [connectWebSocket, disconnectWebSocket]);
// This component doesn't render anything itself
return null;
}

View File

@@ -0,0 +1,185 @@
// ~/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
export function WebRTCConnectionManager() {
console.log('WebRTC Manager: Mounting component.');
const {
serverId: activeServerId,
channelId: activeChannelId,
isVoiceActive,
} = useActiveVoiceChannelStore(state => ({
serverId: state.serverId,
channelId: state.channelId,
isVoiceActive: state.isVoiceActive,
}));
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);
useEffect(() => {
console.log('WebRTC Manager: Effect triggered', {
activeServerId,
activeChannelId,
isVoiceActive,
gatewayStatus,
webRTCStatus,
rtcCurrentChannelId,
operationLock: operationLockRef.current,
});
const manageWebRTCConnection = async () => {
if (operationLockRef.current) {
console.debug('WebRTC Manager: Operation in progress, skipping.');
return;
}
const isConnectedToSomeChannel =
webRTCStatus !== "IDLE" &&
webRTCStatus !== "DISCONNECTED" &&
webRTCStatus !== "FAILED";
// --- Condition to JOIN/SWITCH voice ---
if (isVoiceActive && activeServerId && activeChannelId && gatewayStatus === 'CONNECTED') {
// Condition 1: Not connected at all, and want to join.
// Condition 2: Connected to a DIFFERENT channel, and want to switch.
const needsToJoinOrSwitch =
!isConnectedToSomeChannel || (rtcCurrentChannelId !== activeChannelId);
if (needsToJoinOrSwitch) {
operationLockRef.current = true;
console.log(`WebRTC Manager: Attempting to join/switch to ${activeServerId}/${activeChannelId}. Current RTC status: ${webRTCStatus}, current RTC channel: ${rtcCurrentChannelId}`);
// If currently connected to a different channel, leave it first.
if (isConnectedToSomeChannel && rtcCurrentChannelId && rtcCurrentChannelId !== activeChannelId) {
console.log(`WebRTC Manager: Leaving current channel ${rtcCurrentChannelId} before switching.`);
leaveVoiceChannel();
// leaveVoiceChannel will change webRTCStatus, triggering this effect again.
// The operationLock will be released when status becomes IDLE/DISCONNECTED.
// No 'return' here needed, let the status change from leave drive the next step.
// The lock is set, so next iteration won't try to join immediately.
// It will fall through to the lock release logic when state becomes IDLE.
} else { // Not connected or switching from a null/same channel (should be IDLE then)
let streamToUse = mediaStreamRef.current;
// Acquire media if we don't have a usable stream
if (!streamToUse || streamToUse.getTracks().every(t => t.readyState === 'ended')) {
if (streamToUse) { // Clean up old ended stream
streamToUse.getTracks().forEach(track => track.stop());
}
try {
console.log('WebRTC Manager: Acquiring new local media stream...');
streamToUse = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
mediaStreamRef.current = streamToUse;
} catch (err) {
console.error('WebRTC Manager: Failed to get user media:', err);
useWebRTCStore.setState({ status: "FAILED", lastError: 'Failed to get user media.' });
operationLockRef.current = false; // Release lock on failure
return; // Stop further processing for this run
}
}
if (streamToUse) {
console.log(`WebRTC Manager: Calling joinVoiceChannel for ${activeServerId}/${activeChannelId}`);
await joinVoiceChannel(activeServerId, activeChannelId, streamToUse);
// Don't release lock here immediately; let status changes from joinVoiceChannel
// (e.g., to CONNECTED or FAILED) handle releasing the lock.
} else {
console.error('WebRTC Manager: No media stream available to join channel.');
useWebRTCStore.setState({ status: "FAILED", lastError: 'Media stream unavailable.' });
operationLockRef.current = false; // Release lock
}
}
}
}
// --- Condition to LEAVE voice ---
else { // Not (isVoiceActive && activeServerId && activeChannelId && gatewayStatus === 'CONNECTED')
if (isConnectedToSomeChannel) {
operationLockRef.current = true;
console.log('WebRTC Manager: Conditions met to leave active voice channel. Leaving...', { isVoiceActive, activeServerId, gatewayStatus, webRTCStatus });
leaveVoiceChannel();
// Lock will be released when status becomes IDLE/DISCONNECTED.
}
}
// --- Manage operation lock based on final WebRTC state for this "cycle" ---
// This part is crucial: if an operation was started, the lock is only released
// when the WebRTC state settles into a terminal (IDLE, DISCONNECTED, FAILED) or successful (CONNECTED) state.
if (operationLockRef.current) { // Only if a lock was acquired in this effect run or previous
if (
webRTCStatus === "IDLE" ||
webRTCStatus === "DISCONNECTED" ||
webRTCStatus === "FAILED" ||
(webRTCStatus === "CONNECTED" && rtcCurrentChannelId === activeChannelId && isVoiceActive) // Successfully connected to desired channel
) {
// console.debug(`WebRTC Manager: Releasing operation lock. Status: ${webRTCStatus}`);
operationLockRef.current = false;
}
}
// --- Release media stream if no longer needed ---
// This should happen if WebRTC is inactive AND user doesn't want voice.
if (
mediaStreamRef.current &&
(webRTCStatus === "IDLE" || webRTCStatus === "DISCONNECTED" || webRTCStatus === "FAILED") &&
!isVoiceActive // Only if voice is explicitly deactivated
) {
console.log('WebRTC Manager: Releasing local media stream as WebRTC is inactive and voice is not desired.');
mediaStreamRef.current.getTracks().forEach(track => track.stop());
mediaStreamRef.current = null;
}
};
manageWebRTCConnection();
}, [
activeServerId,
activeChannelId,
isVoiceActive,
gatewayStatus,
webRTCStatus,
rtcCurrentChannelId,
joinVoiceChannel, // Stable from Zustand
leaveVoiceChannel, // Stable from Zustand
]);
// Cleanup on component unmount
useEffect(() => {
return () => {
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;
}
};
}, []); // Empty dependency array for unmount cleanup only
return null;
}

View File

@@ -0,0 +1,20 @@
import { Circle, CircleMinus, Moon } from "lucide-react";
export function OnlineStatus({
status,
...props
}: React.ComponentProps<"div"> & {
status: "online" | "dnd" | "idle" | "offline";
}) {
return (
<div className="relative">
<div {...props}></div>
<div className="absolute bottom-0 right-0 bg-accent 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" />}
{status === "offline" && <Circle className="size-full stroke-gray-400 stroke-3" />}
</div>
</div>
);
}

View File

@@ -0,0 +1,40 @@
import { EyeIcon, EyeOffIcon } from "lucide-react"
import React from "react"
import { Button } from "./ui/button"
import { Input } from "./ui/input"
export function PasswordInput(props: React.ComponentProps<"input">) {
const [showPassword, setShowPassword] = React.useState(false)
const disabled = props.value === '' || props.value === undefined || props.disabled
return (
<div className="relative">
<Input type={showPassword ? "text" : "password"} {...props} />
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
onClick={() => setShowPassword((prev) => !prev)}
disabled={disabled}
>
{showPassword && !disabled ? (
<EyeIcon className="h-4 w-4" aria-hidden="true" />
) : (
<EyeOffIcon className="h-4 w-4" aria-hidden="true" />
)}
<span className="sr-only">{showPassword ? 'Hide password' : 'Show password'}</span>
</Button>
<style>
{`
.hide-password-toggle::-ms-reveal,
.hide-password-toggle::-ms-clear {
visibility: hidden;
pointer-events: none;
display: none;
}
`}
</style>
</div>
)
}

View File

@@ -0,0 +1,43 @@
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>
</>
)
}

125
app/components/text-box.tsx Normal file
View File

@@ -0,0 +1,125 @@
import React, { forwardRef, useEffect, useImperativeHandle, useRef } from 'react';
import { cn } from '~/lib/utils';
export interface TextBoxProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange' | 'value'> {
value: string;
onChange: (value: string) => void;
placeholder?: string;
wrapperClassName?: string;
inputClassName?: string;
disabled?: boolean;
autoFocus?: boolean;
spellCheck?: boolean;
}
export const TextBox = forwardRef<HTMLDivElement, TextBoxProps>(
(
{
value,
onChange,
placeholder,
wrapperClassName,
inputClassName,
disabled = false,
autoFocus = false,
spellCheck = true,
onInput,
onBlur,
onFocus,
...rest
},
ref
) => {
const localRef = useRef<HTMLDivElement>(null);
useImperativeHandle(ref, () => localRef.current as HTMLDivElement);
// Function to handle DOM updates
const updateDOM = (newValue: string) => {
if (localRef.current) {
// Only update if different to avoid selection issues
if (localRef.current.textContent !== newValue) {
localRef.current.textContent = newValue;
// Clear any <br> elements if the content is empty
if (!newValue && localRef.current.innerHTML.includes('<br>')) {
localRef.current.innerHTML = '';
}
}
}
};
// Update DOM when value prop changes
useEffect(() => {
updateDOM(value);
}, [value]);
useEffect(() => {
if (autoFocus && localRef.current) {
localRef.current.focus();
}
}, [autoFocus]);
const handleInput = (event: React.FormEvent<HTMLDivElement>) => {
const newValue = event.currentTarget.textContent || '';
// Handle the case where the content is empty but contains a <br>
if (!newValue && event.currentTarget.innerHTML.includes('<br>')) {
event.currentTarget.innerHTML = '';
}
onChange(newValue);
onInput?.(event);
};
const handlePaste = (event: React.ClipboardEvent<HTMLDivElement>) => {
event.preventDefault();
const text = event.clipboardData.getData('text/plain');
// Use document.execCommand to maintain undo stack
document.execCommand('insertText', false, text);
// Manually trigger input event
const inputEvent = new Event('input', { bubbles: true });
event.currentTarget.dispatchEvent(inputEvent);
};
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",
wrapperClassName
)}
onClick={() => localRef.current?.focus()}
>
<div
ref={localRef}
contentEditable={!disabled}
onInput={handleInput}
onPaste={handlePaste}
onBlur={onBlur}
onFocus={onFocus}
className={cn(
"break-words whitespace-pre-wrap outline-none w-full",
"empty:before:content-[attr(data-placeholder)] empty:before:text-muted-foreground empty:before:cursor-text",
disabled && "cursor-not-allowed opacity-50",
inputClassName
)}
data-placeholder={placeholder}
role="textbox"
aria-multiline="true"
aria-disabled={disabled}
aria-placeholder={placeholder}
spellCheck={spellCheck}
suppressContentEditableWarning
tabIndex={0}
{...rest}
/>
</div>
);
}
);
TextBox.displayName = 'TextBox';
export default TextBox;

View File

@@ -0,0 +1,73 @@
import { createContext, useContext, useEffect, useState } from "react"
type Theme = "dark" | "light" | "system"
type ThemeProviderProps = {
children: React.ReactNode
defaultTheme?: Theme
storageKey?: string
}
type ThemeProviderState = {
theme: Theme
setTheme: (theme: Theme) => void
}
const initialState: ThemeProviderState = {
theme: "system",
setTheme: () => null,
}
const ThemeProviderContext = createContext<ThemeProviderState>(initialState)
export function ThemeProvider({
children,
defaultTheme = "system",
storageKey = "ui-theme",
...props
}: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>(
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
)
useEffect(() => {
const root = window.document.documentElement
root.classList.remove("light", "dark")
if (theme === "system") {
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
.matches
? "dark"
: "light"
root.classList.add(systemTheme)
return
}
root.classList.add(theme)
}, [theme])
const value = {
theme,
setTheme: (theme: Theme) => {
localStorage.setItem(storageKey, theme)
setTheme(theme)
},
}
return (
<ThemeProviderContext.Provider {...props} value={value}>
{children}
</ThemeProviderContext.Provider>
)
}
export const useTheme = () => {
const context = useContext(ThemeProviderContext)
if (context === undefined)
throw new Error("useTheme must be used within a ThemeProvider")
return context
}

View File

@@ -0,0 +1,37 @@
import { Moon, Sun } from "lucide-react"
import { useTheme } from "~/components/theme/theme-provider"
import { Button } from "~/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu"
export function ThemeToggle() {
const { setTheme } = useTheme()
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -0,0 +1,52 @@
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import * as React from "react"
import { cn } from "~/lib/utils"
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
)
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"bg-muted flex size-full items-center justify-center rounded-full",
className
)}
{...props}
/>
)
}
export { Avatar, AvatarFallback, AvatarImage }

View File

@@ -0,0 +1,46 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "~/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,60 @@
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import * as React from "react"
import { cn } from "~/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
none: ""
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@@ -0,0 +1,87 @@
import * as React from "react"
import { cn } from "~/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle
}

View File

@@ -0,0 +1,134 @@
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import * as React from "react"
import { cn } from "~/lib/utils"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background 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 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row",
className
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger
}

View File

@@ -0,0 +1,247 @@
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import * as React from "react"
import { cn } from "~/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
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 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 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",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 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",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-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 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
export {
DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent,
DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuPortal, DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger
}

165
app/components/ui/form.tsx Normal file
View File

@@ -0,0 +1,165 @@
import * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import * as React from "react"
import {
Controller,
FormProvider,
useFormContext,
useFormState,
type ControllerProps,
type FieldPath,
type FieldValues,
} from "react-hook-form"
import { Label } from "~/components/ui/label"
import { cn } from "~/lib/utils"
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName,
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState } = useFormContext()
const formState = useFormState({ name: fieldContext.name })
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string,
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
)
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div
data-slot="form-item"
className={cn("grid gap-2", className)}
{...props}
/>
</FormItemContext.Provider>
)
}
function FormLabel({
className,
required,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root> & { required?: boolean }) {
const { error, formItemId } = useFormField()
return (
<div className="flex items-center gap-1">
<Label
data-slot="form-label"
data-error={!!error}
className={cn("data-[error=true]:text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
{required && <Label className="text-destructive">*</Label>}
</div>
)
}
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
data-slot="form-control"
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
}
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
const { formDescriptionId } = useFormField()
return (
<p
data-slot="form-description"
id={formDescriptionId}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message ?? "") : props.children
if (!body) {
return null
}
return (
<p
data-slot="form-message"
id={formMessageId}
className={cn("text-destructive text-sm", className)}
{...props}
>
{body}
</p>
)
}
export {
Form, FormControl,
FormDescription, FormField, FormItem,
FormLabel, FormMessage, useFormField
}

View File

@@ -0,0 +1,121 @@
import { ImagePlus, XCircle } from "lucide-react";
import React, { useCallback, useEffect, useRef, useState } from "react";
import type { ControllerRenderProps, FieldError } from "react-hook-form";
import { Button } from "./button"; // Your existing Button component
// Props for our custom field component
interface IconUploadFieldProps {
field: ControllerRenderProps<any, string>; // Provided by RHF's FormField render prop
error?: FieldError; // Optional: if you want to pass error for internal styling
accept?: string; // e.g., "image/png, image/jpeg"
previewContainerClassName?: string; // Style for the preview box itself
// Add any other props you might want to customize its appearance/behavior
}
export function IconUploadField({
field,
error,
accept = "image/*",
previewContainerClassName = "w-24 h-24 rounded-full", // Default circular preview
}: IconUploadFieldProps) {
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const currentFileValue = field.value as File | undefined | null;
useEffect(() => {
let objectUrl: string | null = null;
if (currentFileValue && currentFileValue instanceof File) {
objectUrl = URL.createObjectURL(currentFileValue);
setPreviewUrl(objectUrl);
} else {
setPreviewUrl(null);
}
return () => {
if (objectUrl) {
URL.revokeObjectURL(objectUrl);
}
};
}, [currentFileValue]);
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
field.onChange(file || undefined);
if (event.target) {
event.target.value = "";
}
};
const handleRemoveImage = useCallback((e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
field.onChange(undefined);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}, [field]);
const triggerFileInput = useCallback(() => {
fileInputRef.current?.click();
}, []);
return (
<div className="flex items-center space-x-4">
{/* Main clickable area for upload, also receives RHF's ref */}
<div
ref={field.ref} // Attach RHF's ref here for focus management
className={`relative ${previewContainerClassName} border-2 ${error ? "border-destructive" : "border-dashed border-muted-foreground"
} flex items-center justify-center cursor-pointer hover:border-primary transition-colors overflow-hidden`}
onClick={triggerFileInput}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
triggerFileInput();
}
}}
role="button"
tabIndex={0}
aria-label={previewUrl ? "Change icon" : "Upload icon"}
aria-invalid={!!error}
aria-describedby={error ? `${field.name}-error` : undefined}
>
{previewUrl ? (
<img
src={previewUrl}
alt="Icon preview"
className="w-full h-full object-cover"
/>
) : (
<ImagePlus className={`w-10 h-10 ${error ? "text-destructive" : "text-muted-foreground"}`} />
)}
<input
type="file"
ref={fileInputRef} // Internal ref for programmatic click
className="hidden" // Visually hidden
accept={accept}
onChange={handleFileChange}
onBlur={field.onBlur} // RHF's onBlur for touched state
name={field.name} // RHF's field name
// The `value` of a file input is not directly controlled by RHF's `field.value` (which is a File object)
/>
</div>
<div className="flex flex-col space-y-2">
{/* <Button type="button" variant="outline" size="sm" onClick={triggerFileInput}>
{previewUrl ? "Change Icon" : "Upload Icon"}
</Button> */}
{currentFileValue && ( // Show remove button only if a file is selected
<Button
type="button"
variant="destructive"
size="sm"
onClick={handleRemoveImage}
>
<XCircle className="" />
Remove
</Button>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "~/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }

View File

@@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "~/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@@ -0,0 +1,79 @@
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import * as React from "react"
import { cn } from "~/lib/utils"
function ScrollArea({
className,
children,
scrollbarSize,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root> & {
scrollbarSize?: "default" | "narrow" | "none"
}) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar size={scrollbarSize} />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
size = "default",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar> & {
size?: "default" | "narrow" | "none"
}) {
const classes = {
vertical: {
className: "h-full border-l border-l-transparent",
size: {
default: "w-2.5",
narrow: "w-1.5",
none: "hidden",
},
},
horizontal: {
className: "flex-col border-t border-t-transparent",
size: {
default: "h-2.5",
narrow: "h-1.5",
none: "hidden",
},
},
}
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
classes[orientation].className,
classes[orientation].size[size],
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }

View File

@@ -0,0 +1,26 @@
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "~/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator-root"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }

View File

@@ -0,0 +1,64 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "~/lib/utils"
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className
)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "~/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder: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 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
)
}
export { Textarea }

View File

@@ -0,0 +1,59 @@
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "~/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
)
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@@ -0,0 +1,34 @@
import axios from "../http-client"
import type { User } from "../types"
interface RegisterRequest {
email: string
username: string
displayName?: string
password: string
}
interface LoginRequest {
username: string
password: string
}
interface LoginResponse {
user: User
token: string
}
export async function register(request: RegisterRequest) {
await axios.post("/auth/register", request)
}
export async function login(request: LoginRequest) {
const response = await axios.post("/auth/login", request)
return response.data as LoginResponse
}
export default {
register,
login,
}

View File

@@ -0,0 +1,24 @@
import axios from "../http-client"
import type { Server } from "../types"
interface CreateServerRequest {
name: string
icon?: File
}
export async function create(request: CreateServerRequest) {
const response = await axios.postForm("/servers", request)
return response.data as Server
}
export async function list() {
const response = await axios.get("/servers")
return response.data as Server[]
}
export default {
create,
list,
}

View File

@@ -0,0 +1,14 @@
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
}

View File

@@ -0,0 +1,19 @@
import axios from "../http-client"
import type { RecipientChannel, User } from "../types"
export async function me() {
const response = await axios.get("/users/@me")
return response.data as User
}
export async function channels() {
const response = await axios.get("/users/@me/channels")
return response.data as RecipientChannel[]
}
export default {
me,
channels
}

View File

@@ -0,0 +1,23 @@
import axios from "axios"
import { useTokenStore } from "~/store/token"
import { API_URL } from "../consts"
axios.interceptors.request.use(
(config) => {
const token = useTokenStore.getState().token
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => {
return Promise.reject(error)
}
)
axios.defaults.baseURL = API_URL
axios.defaults.headers.common["Content-Type"] = "application/json"
export default axios

36
app/lib/api/types.ts Normal file
View File

@@ -0,0 +1,36 @@
export type Uuid = string
export interface User {
id: Uuid
avatarUrl?: string
username: string
displayName?: string
email: string
bot: boolean
system: boolean
settings: any
}
export interface Server {
id: Uuid
name: string
icon_url?: string
owner: Uuid
}
export interface RecipientChannel {
id: Uuid
name: string
type: string
lastMessageId?: Uuid
recipients: PartialUser[]
}
export interface PartialUser {
id: Uuid
username: string
displayName?: string
avatarUrl?: string,
bot: boolean
system: boolean
}

1
app/lib/consts.ts Normal file
View File

@@ -0,0 +1 @@
export const API_URL = "http://localhost:12345/api/v1"

14
app/lib/utils.ts Normal file
View File

@@ -0,0 +1,14 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function getFirstLetters(str: string, n: number): string {
return str
.split(/\s+/)
.slice(0, n)
.map(word => word[0] || '')
.join('');
}

View File

@@ -0,0 +1,79 @@
// ~/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;

View File

@@ -0,0 +1,33 @@
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 };

View File

@@ -7,7 +7,9 @@ import {
ScrollRestoration, ScrollRestoration,
} from "react-router"; } from "react-router";
import { ThemeProvider } from "~/components/theme/theme-provider";
import type { Route } from "./+types/root"; import type { Route } from "./+types/root";
import "./app.css"; import "./app.css";
export const links: Route.LinksFunction = () => [ export const links: Route.LinksFunction = () => [
@@ -23,6 +25,14 @@ export const links: Route.LinksFunction = () => [
}, },
]; ];
export default function App() {
return (
<ThemeProvider defaultTheme="system" storageKey="ui-theme">
<Outlet />
</ThemeProvider>
);
}
export function Layout({ children }: { children: React.ReactNode }) { export function Layout({ children }: { children: React.ReactNode }) {
return ( return (
<html lang="en"> <html lang="en">
@@ -41,10 +51,6 @@ export function Layout({ children }: { children: React.ReactNode }) {
); );
} }
export default function App() {
return <Outlet />;
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
let message = "Oops!"; let message = "Oops!";
let details = "An unexpected error occurred."; let details = "An unexpected error occurred.";

View File

@@ -1,3 +1,26 @@
import { type RouteConfig, index } from "@react-router/dev/routes"; import { type RouteConfig, index, layout, prefix, route } from "@react-router/dev/routes";
export default [index("routes/home.tsx")] satisfies RouteConfig; export default [
index("routes/index.tsx"),
layout("routes/auth/layout.tsx", [
route("/login", "routes/auth/login.tsx"),
route("/register", "routes/auth/register.tsx"),
]),
...prefix("/app", [
layout("routes/app/layout.tsx", [
index("routes/app/index.tsx"),
...prefix("/@me", [
layout("routes/app/me/layout.tsx", [
index("routes/app/me/index.tsx"),
route("/channels/:channelId", "routes/app/me/channel.tsx"),
])
]),
...prefix("/server/:serverId", [
layout("routes/app/server/layout.tsx", [
index("routes/app/server/index.tsx"),
route("/channels/:channelId", "routes/app/server/channel.tsx"),
])
])
])
]),
] satisfies RouteConfig;

58
app/routes/app/index.tsx Normal file
View File

@@ -0,0 +1,58 @@
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";
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 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>
</>
);
}

43
app/routes/app/layout.tsx Normal file
View File

@@ -0,0 +1,43 @@
import type React from "react";
import { Outlet, redirect, useMatches } 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";
export async function clientLoader() {
let { user, setUser } = useUserStore.getState()
try {
if (!user) {
const user = await import("~/lib/api/client/user").then(m => m.default.me())
setUser(user)
}
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)
return (
<>
<GatewayWebSocketConnectionManager />
<WebRTCConnectionManager />
{/* <GlobalWebRTCAudioPlayer /> */}
<AppLayout list={list}>
<Outlet />
</AppLayout>
</>
);
}

View File

@@ -0,0 +1,69 @@
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";
export default function Channel({
params
}: Route.ComponentProps) {
let channelId = params.channelId
const audioRef = useRef<HTMLAudioElement>(null)
let peerConnection: RTCPeerConnection | null = null
async function testSdp(channelId: Uuid) {
const stream = await navigator.mediaDevices.getUserMedia(
{
audio: true,
}
);
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);
}
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>
</>
);
}

View File

@@ -0,0 +1,23 @@
import TextBox from "~/components/text-box";
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">
<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>
</>
);
}

View File

@@ -0,0 +1,49 @@
import { Outlet } from "react-router";
import PrivateChannelListItem from "~/components/private-channel-list-item";
import { ScrollArea } from "~/components/ui/scroll-area";
import { usePrivateChannelsStore } from "~/store/private-channels";
export async function clientLoader() {
const {channels, setChannels} = usePrivateChannelsStore.getState()
if (!channels || channels.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)
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">
Private Messages
</div>
</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, _) => (
<>
<PrivateChannelListItem channel={channel} />
</>
))}
</div>
</ScrollArea>
</div>
)
}
export const handle = {
listComponent: <ListComponent />
}
export default function Layout() {
return (
<>
<Outlet />
</>
);
}

View File

@@ -0,0 +1,23 @@
import TextBox from "~/components/text-box";
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>
</>
);
}

View File

@@ -0,0 +1,23 @@
import TextBox from "~/components/text-box";
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">
<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>
</>
);
}

View File

@@ -0,0 +1,53 @@
import { Outlet, useParams } from "react-router";
import ChannelListItem from "~/components/channel-list-item";
import { ScrollArea } from "~/components/ui/scroll-area";
import { usePrivateChannelsStore } from "~/store/private-channels";
import { useServerListStore } from "~/store/server-list";
export async function clientLoader() {
const {channels, setChannels} = usePrivateChannelsStore.getState()
if (!channels || channels.length === 0) {
const channels = await import("~/lib/api/client/user").then(m => m.default.channels())
setChannels(channels)
}
}
function ListComponent() {
const channels = []
const serverId = useParams<{ serverId: string }>().serverId!
const server = useServerListStore(state => state.servers.get(serverId))
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">
{server?.name}
</div>
</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} />
</>
))}
</div>
</ScrollArea>
</div>
)
}
export const handle = {
listComponent: <ListComponent />
}
export default function Layout() {
return (
<>
<Outlet />
</>
);
}

View File

@@ -0,0 +1,11 @@
import { Outlet } from "react-router";
export default function Layout() {
return (
<div className="min-h-screen min-w-screen grid place-items-center">
<div className="min-w-md">
<Outlet />
</div>
</div>
);
}

114
app/routes/auth/login.tsx Normal file
View File

@@ -0,0 +1,114 @@
import { zodResolver } from "@hookform/resolvers/zod";
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 { ThemeToggle } from "~/components/theme/theme-toggle";
import { Button, buttonVariants } from "~/components/ui/button";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "~/components/ui/card";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} 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";
const schema = z.object({
username: z.string(),
password: z.string().min(8),
});
export async function clientLoader() {
const { token, setToken } = useTokenStore.getState()
if (token) {
try {
await import("~/lib/api/client/user").then(m => m.default.me())
return redirect("/app/@me")
} catch (error) {
const axiosError = error as AxiosError
if (axiosError.status === 401) {
setToken(undefined)
}
}
}
}
export default function Login() {
let navigate = useNavigate()
let setToken = useTokenStore(state => state.setToken)
let setUser = useUserStore(state => state.setUser)
const form = useForm<z.infer<typeof schema>>({
resolver: zodResolver(schema),
})
async function onSubmit(values: z.infer<typeof schema>) {
const response = await auth.login(values)
setToken(response.token)
setUser(response.user)
navigate("/app")
}
return (
<Card style={{ viewTransitionName: "auth-card-view" }}>
<CardHeader style={{ viewTransitionName: "auth-card-header-view" }} className="relative" >
<CardTitle>Welcome back!</CardTitle>
<CardDescription>Please sign in to continue.</CardDescription>
<div className="absolute top-0 right-0 px-6" style={{ viewTransitionName: "auth-card-header-mode-toggle-view" }}>
<ThemeToggle />
</div>
</CardHeader>
<CardContent style={{ viewTransitionName: "auth-card-content-view" }}>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4" style={{ viewTransitionName: "auth-form-view" }}>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem style={{ viewTransitionName: "email-field-view" }}>
<FormLabel required>Username</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem style={{ viewTransitionName: "password-field-view" }}>
<FormLabel required>Password</FormLabel>
<FormControl>
<PasswordInput {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button className="w-full" type="submit" style={{ viewTransitionName: "submit-button-view" }}>Log In</Button>
</form>
</Form>
</CardContent>
<CardFooter style={{ viewTransitionName: "auth-card-footer-view" }}>
<div className="flex items-center">
<span className="text-muted-foreground text-sm">Don't have an account?</span>
<Link className={buttonVariants({ variant: "link", size: "sm" })} to="/register" viewTransition>Register</Link>
</div>
</CardFooter>
</Card>
);
}

View File

@@ -0,0 +1,116 @@
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 { ThemeToggle } from "~/components/theme/theme-toggle";
import { Button, buttonVariants } from "~/components/ui/button";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "~/components/ui/card";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "~/components/ui/form";
import { Input } from "~/components/ui/input";
import auth from "~/lib/api/client/auth";
const schema = z.object({
email: z.string().email(),
displayName: z.string().min(1).optional(),
username: z.string().min(3).regex(/^[a-zA-Z0-9_.]+$/),
password: z.string().min(8),
});
export default function Register() {
let navigate = useNavigate()
const form = useForm<z.infer<typeof schema>>({
resolver: zodResolver(schema),
})
async function onSubmit(values: z.infer<typeof schema>) {
await auth.register(values)
navigate("/login")
}
return (
<Card style={{ viewTransitionName: "auth-card-view" }}>
<CardHeader style={{ viewTransitionName: "auth-card-header-view" }} className="relative">
<CardTitle>Create an account</CardTitle>
<CardDescription>Please fill out the form below to create an account.</CardDescription>
<div className="absolute top-0 right-0 px-6" style={{ viewTransitionName: "auth-card-header-mode-toggle-view" }}>
<ThemeToggle />
</div>
</CardHeader>
<CardContent style={{ viewTransitionName: "auth-card-content-view" }}>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4" style={{ viewTransitionName: "auth-form-view" }}>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem style={{ viewTransitionName: "email-field-view" }}>
<FormLabel required>Email</FormLabel>
<FormControl>
<Input type="email" placeholder="email@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel required>Username</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="displayName"
render={({ field }) => (
<FormItem>
<FormLabel>Display Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem style={{ viewTransitionName: "password-field-view" }}>
<FormLabel required>Password</FormLabel>
<FormControl>
<PasswordInput {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button className="w-full" type="submit" style={{ viewTransitionName: "submit-button-view" }}>Register</Button>
</form>
</Form>
</CardContent>
<CardFooter style={{ viewTransitionName: "auth-card-footer-view" }}>
<div className="flex items-center">
<span className="text-muted-foreground text-sm">Already have an account?</span>
<Link className={buttonVariants({ variant: "link", size: "sm" })} to="/login" viewTransition>Log In</Link>
</div>
</CardFooter>
</Card>
);
}

View File

@@ -1,13 +0,0 @@
import type { Route } from "./+types/home";
import { Welcome } from "../welcome/welcome";
export function meta({}: Route.MetaArgs) {
return [
{ title: "New React Router App" },
{ name: "description", content: "Welcome to React Router!" },
];
}
export default function Home() {
return <Welcome />;
}

16
app/routes/index.tsx Normal file
View File

@@ -0,0 +1,16 @@
import { redirect } from "react-router";
import type { Route } from "./+types/index";
export function meta({ }: Route.MetaArgs) {
return [
{ title: "New React Router App" },
];
}
export function clientLoader() {
return redirect("/login");
}
export default function Index() {
return <></>;
}

View File

@@ -0,0 +1,39 @@
// ~/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}`);
},
}));

View File

@@ -0,0 +1,435 @@
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);
}

View File

@@ -0,0 +1,14 @@
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 }),
})
)

View File

@@ -0,0 +1,20 @@
// 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 }
// }),
// })
// )

22
app/store/server-list.ts Normal file
View File

@@ -0,0 +1,22 @@
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 }
}),
})
)

21
app/store/token.ts Normal file
View File

@@ -0,0 +1,21 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
type TokenStore = {
token?: string
setToken: (token?: string) => void
removeToken: () => void
}
export const useTokenStore = create<TokenStore>()(
persist(
(set, get) => ({
token: undefined,
setToken: (token?: string) => set({ token }),
removeToken: () => set({ token: undefined }),
}),
{
name: 'token',
},
),
)

14
app/store/user.ts Normal file
View File

@@ -0,0 +1,14 @@
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 }),
}),
)

View File

@@ -0,0 +1,408 @@
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);
}

332
app/store/webrtc.ts Normal file
View File

@@ -0,0 +1,332 @@
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);
// });
// }
// },
}));

View File

@@ -1,23 +0,0 @@
<svg width="1080" height="174" viewBox="0 0 1080 174" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M231.527 86.9999C231.527 94.9642 228.297 102.173 223.067 107.387C217.837 112.606 210.614 115.835 202.634 115.835C194.654 115.835 187.43 119.059 182.206 124.278C176.977 129.498 173.741 136.707 173.741 144.671C173.741 152.635 170.51 159.844 165.281 165.058C160.051 170.277 152.828 173.507 144.847 173.507C136.867 173.507 129.644 170.277 124.42 165.058C119.19 159.844 115.954 152.635 115.954 144.671C115.954 136.707 119.19 129.498 124.42 124.278C129.644 119.059 136.867 115.835 144.847 115.835C152.828 115.835 160.051 112.606 165.281 107.387C170.51 102.173 173.741 94.9642 173.741 86.9999C173.741 71.0711 160.808 58.1643 144.847 58.1643C136.867 58.1643 129.644 54.9347 124.42 49.7155C119.19 44.502 115.954 37.2931 115.954 29.3287C115.954 21.3643 119.19 14.1555 124.42 8.93622C129.644 3.71698 136.867 0.493164 144.847 0.493164C160.808 0.493164 173.741 13.4 173.741 29.3287C173.741 37.2931 176.977 44.502 182.206 49.7155C187.43 54.9347 194.654 58.1643 202.634 58.1643C218.594 58.1643 231.527 71.0711 231.527 86.9999Z" fill="#F44250"/>
<path d="M115.954 86.9996C115.954 71.0742 103.018 58.1641 87.061 58.1641C71.1037 58.1641 58.1677 71.0742 58.1677 86.9996C58.1677 102.925 71.1037 115.835 87.061 115.835C103.018 115.835 115.954 102.925 115.954 86.9996Z" fill="white"/>
<path d="M58.1676 144.671C58.1676 128.745 45.2316 115.835 29.2743 115.835C13.317 115.835 0.381104 128.745 0.381104 144.671C0.381104 160.596 13.317 173.506 29.2743 173.506C45.2316 173.506 58.1676 160.596 58.1676 144.671Z" fill="white"/>
<path d="M289.314 144.671C289.314 128.745 276.378 115.835 260.42 115.835C244.463 115.835 231.527 128.745 231.527 144.671C231.527 160.596 244.463 173.506 260.42 173.506C276.378 173.506 289.314 160.596 289.314 144.671Z" fill="white"/>
<g clip-path="url(#clip0_202_2131)">
<path d="M562.482 173.247C524.388 173.247 498.363 147.49 498.363 110.468C498.363 73.4455 524.388 47.6885 562.482 47.6885C600.576 47.6885 626.869 73.7135 626.869 110.468C626.869 147.222 600.576 173.247 562.482 173.247ZM562.482 144.007C579.385 144.007 587.703 130.319 587.703 110.468C587.703 90.6168 579.385 76.9289 562.482 76.9289C545.579 76.9289 537.529 90.6168 537.529 110.468C537.529 130.319 545.311 144.007 562.482 144.007Z" fill="white"/>
<path d="M833.64 141.116C824.217 141.116 819.237 136.684 819.237 126.156V74.8983H851.928V47.7792H819.237V1.15527H791.75L786.1 26.1978C783.343 36.4805 780.82 42.822 773.897 46.0821C773.105 46.4506 771.129 46.9976 769.409 47.3884C768.014 47.701 766.596 47.8573 765.167 47.8573H752.338V47.9243H734.832C723.578 47.9243 714.445 57.0459 714.445 68.3111V111.552C714.445 130.599 707.199 142.668 692.719 142.668C678.238 142.668 672.868 133.279 672.868 116.375V47.9243H634.249V125.765C634.249 151.254 644.442 173.248 676.63 173.248C691.915 173.248 703.895 167.231 711.096 157.182C712.145 155.72 714.445 156.49 714.445 158.276V170.022H753.332V83.8412C753.332 78.8953 757.34 74.8871 762.286 74.8871H779.882V136.952C779.882 164.663 797.89 173.248 817.842 173.248C833.908 173.248 844.436 169.374 853.58 162.441V136.126C846.1 139.453 839.725 141.116 833.629 141.116H833.64Z" fill="white"/>
<path d="M981.561 130.865C975.387 157.962 954.197 173.258 923.07 173.258C885.243 173.258 858.415 150.18 858.415 112.354C858.415 74.5281 885.779 47.6992 922.266 47.6992C961.699 47.6992 982.365 74.796 982.365 107.263V113.884H896.509C894.555 135.711 909.382 144.017 924.409 144.017C937.829 144.017 946.136 138.915 950.434 127.918L981.561 130.865ZM945.075 94.9372C944.271 83.1361 936.757 75.8567 921.998 75.8567C906.434 75.8567 899.188 82.321 897.045 94.9372H945.064H945.075Z" fill="white"/>
<path d="M1076.24 85.7486C1070.06 82.2652 1064.17 80.9142 1055.85 80.9142C1039.75 80.9142 1029.02 90.0358 1029.02 110.691V170.02H990.393V47.9225H1029.02V64.3235C1029.02 65.4623 1030.54 65.8195 1031.05 64.8035C1036.68 53.5718 1047.91 44.707 1062.03 44.707C1069.27 44.707 1075.45 46.8507 1078.66 49.5414L1076.25 85.7597L1076.24 85.7486Z" fill="white"/>
<path d="M547.32 31.5345V23.9983H522.457V31.5345H515.378V2.23828H542.14C553.562 2.23828 554.365 2.95282 554.365 13.1239C554.365 17.4111 553.472 18.5611 551.329 19.6553L549.408 20.6378L551.317 21.6426C553.595 22.8372 554.365 23.2391 554.365 30.0273V31.5345H547.332H547.32ZM522.457 18.3601H547.32V7.88763H522.457V18.349V18.3601Z" fill="white"/>
<path d="M578.493 2.23828H610.826V7.90996H580.067V14.5083H610.011V19.2868H580.067V25.8963H610.837V31.501L578.504 31.5345C575.344 31.5345 572.787 28.9778 572.787 25.8293V7.95462C572.787 4.80617 575.344 2.24945 578.493 2.24945V2.23828Z" fill="white"/>
<path d="M655.562 31.5345L653.151 26.3429H633.746L631.335 31.5345H624.58L637.006 4.75034C637.71 3.22078 639.262 2.23828 640.936 2.23828H645.927C647.613 2.23828 649.154 3.22078 649.857 4.75034L662.283 31.5345H655.529H655.562ZM643.46 8.06627C642.712 8.06627 642.053 8.49053 641.729 9.17158L635.968 21.5756H650.94L645.19 9.17158C644.878 8.49053 644.208 8.06627 643.46 8.06627Z" fill="white"/>
<path d="M694.862 32.4153C676.05 32.4153 675.313 32.4153 675.313 16.8852C675.313 1.35505 676.05 1.36621 694.862 1.36621C711.721 1.36621 713.764 2.06959 714.244 10.5325H707.333V7.01556H682.168V26.766H707.333V23.2714H714.244C713.775 31.7119 711.721 32.4153 694.862 32.4153Z" fill="white"/>
<path d="M745.282 31.5345V7.02795H729.16V2.23828H768.147V7.02795H752.025V31.5345H745.282Z" fill="white"/>
<path d="M454.419 169.819C450.935 165.264 448.792 154.814 447.452 137.397C446.112 118.104 437.806 113.817 422.532 113.817H392.254V169.83H347.494V0.986328H432.715C476.391 0.986328 498.106 21.6187 498.106 54.5882C498.106 79.2399 482.833 95.3171 462.201 98.0078C479.618 101.491 489.8 111.405 491.675 130.966C494.087 156.154 494.891 163.656 500.518 169.819H454.419ZM424.676 78.704C443.969 78.704 453.615 73.8808 453.615 58.3395C453.615 44.6739 443.969 37.4392 424.676 37.4392H392.254V78.7152H424.676V78.704Z" fill="white"/>
</g>
<defs>
<clipPath id="clip0_202_2131">
<rect width="731.156" height="172.261" fill="white" transform="translate(347.494 0.986328)"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 6.0 KiB

View File

@@ -1,23 +0,0 @@
<svg width="1080" height="174" viewBox="0 0 1080 174" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M231.527 86.9999C231.527 94.9642 228.297 102.173 223.067 107.387C217.837 112.606 210.614 115.835 202.634 115.835C194.654 115.835 187.43 119.059 182.206 124.278C176.977 129.498 173.741 136.707 173.741 144.671C173.741 152.635 170.51 159.844 165.281 165.058C160.051 170.277 152.828 173.507 144.847 173.507C136.867 173.507 129.644 170.277 124.42 165.058C119.19 159.844 115.954 152.635 115.954 144.671C115.954 136.707 119.19 129.498 124.42 124.278C129.644 119.059 136.867 115.835 144.847 115.835C152.828 115.835 160.051 112.606 165.281 107.387C170.51 102.173 173.741 94.9642 173.741 86.9999C173.741 71.0711 160.808 58.1643 144.847 58.1643C136.867 58.1643 129.644 54.9347 124.42 49.7155C119.19 44.502 115.954 37.2931 115.954 29.3287C115.954 21.3643 119.19 14.1555 124.42 8.93622C129.644 3.71698 136.867 0.493164 144.847 0.493164C160.808 0.493164 173.741 13.4 173.741 29.3287C173.741 37.2931 176.977 44.502 182.206 49.7155C187.43 54.9347 194.654 58.1643 202.634 58.1643C218.594 58.1643 231.527 71.0711 231.527 86.9999Z" fill="#F44250"/>
<path d="M115.954 86.9996C115.954 71.0742 103.018 58.1641 87.0608 58.1641C71.1035 58.1641 58.1676 71.0742 58.1676 86.9996C58.1676 102.925 71.1035 115.835 87.0608 115.835C103.018 115.835 115.954 102.925 115.954 86.9996Z" fill="#121212"/>
<path d="M58.1676 144.671C58.1676 128.745 45.2316 115.835 29.2743 115.835C13.317 115.835 0.381104 128.745 0.381104 144.671C0.381104 160.596 13.317 173.506 29.2743 173.506C45.2316 173.506 58.1676 160.596 58.1676 144.671Z" fill="#121212"/>
<path d="M289.313 144.671C289.313 128.745 276.378 115.835 260.42 115.835C244.463 115.835 231.527 128.745 231.527 144.671C231.527 160.596 244.463 173.506 260.42 173.506C276.378 173.506 289.313 160.596 289.313 144.671Z" fill="#121212"/>
<g clip-path="url(#clip0_171_1761)">
<path d="M562.482 173.247C524.388 173.247 498.363 147.49 498.363 110.468C498.363 73.4455 524.388 47.6885 562.482 47.6885C600.576 47.6885 626.869 73.7135 626.869 110.468C626.869 147.222 600.576 173.247 562.482 173.247ZM562.482 144.007C579.386 144.007 587.703 130.319 587.703 110.468C587.703 90.6168 579.386 76.9289 562.482 76.9289C545.579 76.9289 537.529 90.6168 537.529 110.468C537.529 130.319 545.311 144.007 562.482 144.007Z" fill="#121212"/>
<path d="M833.64 141.116C824.217 141.116 819.237 136.684 819.237 126.156V74.8983H851.928V47.7792H819.237V1.15527H791.75L786.1 26.1978C783.343 36.4805 780.82 42.822 773.897 46.0821C773.105 46.4506 771.129 46.9976 769.409 47.3884C768.014 47.701 766.596 47.8573 765.167 47.8573H752.338V47.9243H734.832C723.578 47.9243 714.445 57.0459 714.445 68.3111V111.552C714.445 130.599 707.199 142.668 692.719 142.668C678.238 142.668 672.868 133.279 672.868 116.375V47.9243H634.249V125.765C634.249 151.254 644.442 173.248 676.63 173.248C691.915 173.248 703.895 167.231 711.096 157.182C712.145 155.72 714.445 156.49 714.445 158.276V170.022H753.332V83.8412C753.332 78.8953 757.34 74.8871 762.286 74.8871H779.882V136.952C779.882 164.663 797.89 173.248 817.842 173.248C833.908 173.248 844.436 169.374 853.58 162.441V136.126C846.1 139.453 839.725 141.116 833.629 141.116H833.64Z" fill="#121212"/>
<path d="M981.561 130.865C975.387 157.962 954.197 173.258 923.07 173.258C885.243 173.258 858.415 150.18 858.415 112.354C858.415 74.5281 885.779 47.6992 922.266 47.6992C961.699 47.6992 982.365 74.796 982.365 107.263V113.884H896.509C894.555 135.711 909.382 144.017 924.409 144.017C937.829 144.017 946.136 138.915 950.434 127.918L981.561 130.865ZM945.075 94.9372C944.271 83.1361 936.757 75.8567 921.998 75.8567C906.434 75.8567 899.188 82.321 897.045 94.9372H945.064H945.075Z" fill="#121212"/>
<path d="M1076.24 85.7486C1070.06 82.2652 1064.17 80.9142 1055.85 80.9142C1039.75 80.9142 1029.02 90.0358 1029.02 110.691V170.02H990.393V47.9225H1029.02V64.3235C1029.02 65.4623 1030.54 65.8195 1031.05 64.8035C1036.68 53.5718 1047.91 44.707 1062.03 44.707C1069.27 44.707 1075.45 46.8507 1078.66 49.5414L1076.25 85.7597L1076.24 85.7486Z" fill="#121212"/>
<path d="M547.321 31.5345V23.9983H522.457V31.5345H515.378V2.23828H542.14C553.562 2.23828 554.366 2.95282 554.366 13.1239C554.366 17.4111 553.472 18.5611 551.329 19.6553L549.408 20.6378L551.318 21.6426C553.595 22.8372 554.366 23.2391 554.366 30.0273V31.5345H547.332H547.321ZM522.457 18.3601H547.321V7.88763H522.457V18.349V18.3601Z" fill="#121212"/>
<path d="M578.493 2.23828H610.826V7.90996H580.067V14.5083H610.011V19.2868H580.067V25.8963H610.837V31.501L578.504 31.5345C575.344 31.5345 572.787 28.9778 572.787 25.8293V7.95462C572.787 4.80617 575.344 2.24945 578.493 2.24945V2.23828Z" fill="#121212"/>
<path d="M655.562 31.5345L653.151 26.3429H633.747L631.335 31.5345H624.58L637.007 4.75034C637.71 3.22078 639.262 2.23828 640.937 2.23828H645.927C647.613 2.23828 649.154 3.22078 649.857 4.75034L662.284 31.5345H655.529H655.562ZM643.46 8.06627C642.712 8.06627 642.053 8.49053 641.729 9.17158L635.968 21.5756H650.94L645.19 9.17158C644.878 8.49053 644.208 8.06627 643.46 8.06627Z" fill="#121212"/>
<path d="M694.862 32.4153C676.05 32.4153 675.313 32.4153 675.313 16.8852C675.313 1.35505 676.05 1.36621 694.862 1.36621C711.721 1.36621 713.764 2.06959 714.244 10.5325H707.333V7.01556H682.168V26.766H707.333V23.2714H714.244C713.775 31.7119 711.721 32.4153 694.862 32.4153Z" fill="#121212"/>
<path d="M745.282 31.5345V7.02795H729.16V2.23828H768.148V7.02795H752.026V31.5345H745.282Z" fill="#121212"/>
<path d="M454.419 169.819C450.935 165.264 448.792 154.814 447.452 137.397C446.112 118.104 437.806 113.817 422.532 113.817H392.254V169.83H347.494V0.986328H432.715C476.391 0.986328 498.106 21.6187 498.106 54.5882C498.106 79.2399 482.833 95.3171 462.201 98.0078C479.618 101.491 489.8 111.405 491.676 130.966C494.087 156.154 494.891 163.656 500.518 169.819H454.419ZM424.676 78.704C443.969 78.704 453.615 73.8808 453.615 58.3395C453.615 44.6739 443.969 37.4392 424.676 37.4392H392.254V78.7152H424.676V78.704Z" fill="#121212"/>
</g>
<defs>
<clipPath id="clip0_171_1761">
<rect width="731.156" height="172.261" fill="white" transform="translate(347.494 0.986328)"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 6.0 KiB

View File

@@ -1,89 +0,0 @@
import logoDark from "./logo-dark.svg";
import logoLight from "./logo-light.svg";
export function Welcome() {
return (
<main className="flex items-center justify-center pt-16 pb-4">
<div className="flex-1 flex flex-col items-center gap-16 min-h-0">
<header className="flex flex-col items-center gap-9">
<div className="w-[500px] max-w-[100vw] p-4">
<img
src={logoLight}
alt="React Router"
className="block w-full dark:hidden"
/>
<img
src={logoDark}
alt="React Router"
className="hidden w-full dark:block"
/>
</div>
</header>
<div className="max-w-[300px] w-full space-y-6 px-4">
<nav className="rounded-3xl border border-gray-200 p-6 dark:border-gray-700 space-y-4">
<p className="leading-6 text-gray-700 dark:text-gray-200 text-center">
What&apos;s next?
</p>
<ul>
{resources.map(({ href, text, icon }) => (
<li key={href}>
<a
className="group flex items-center gap-3 self-stretch p-3 leading-normal text-blue-700 hover:underline dark:text-blue-500"
href={href}
target="_blank"
rel="noreferrer"
>
{icon}
{text}
</a>
</li>
))}
</ul>
</nav>
</div>
</div>
</main>
);
}
const resources = [
{
href: "https://reactrouter.com/docs",
text: "React Router Docs",
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="20"
viewBox="0 0 20 20"
fill="none"
className="stroke-gray-600 group-hover:stroke-current dark:stroke-gray-300"
>
<path
d="M9.99981 10.0751V9.99992M17.4688 17.4688C15.889 19.0485 11.2645 16.9853 7.13958 12.8604C3.01467 8.73546 0.951405 4.11091 2.53116 2.53116C4.11091 0.951405 8.73546 3.01467 12.8604 7.13958C16.9853 11.2645 19.0485 15.889 17.4688 17.4688ZM2.53132 17.4688C0.951566 15.8891 3.01483 11.2645 7.13974 7.13963C11.2647 3.01471 15.8892 0.951453 17.469 2.53121C19.0487 4.11096 16.9854 8.73551 12.8605 12.8604C8.73562 16.9853 4.11107 19.0486 2.53132 17.4688Z"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
),
},
{
href: "https://rmx.as/discord",
text: "Join Discord",
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="20"
viewBox="0 0 24 20"
fill="none"
className="stroke-gray-600 group-hover:stroke-current dark:stroke-gray-300"
>
<path
d="M15.0686 1.25995L14.5477 1.17423L14.2913 1.63578C14.1754 1.84439 14.0545 2.08275 13.9422 2.31963C12.6461 2.16488 11.3406 2.16505 10.0445 2.32014C9.92822 2.08178 9.80478 1.84975 9.67412 1.62413L9.41449 1.17584L8.90333 1.25995C7.33547 1.51794 5.80717 1.99419 4.37748 2.66939L4.19 2.75793L4.07461 2.93019C1.23864 7.16437 0.46302 11.3053 0.838165 15.3924L0.868838 15.7266L1.13844 15.9264C2.81818 17.1714 4.68053 18.1233 6.68582 18.719L7.18892 18.8684L7.50166 18.4469C7.96179 17.8268 8.36504 17.1824 8.709 16.4944L8.71099 16.4904C10.8645 17.0471 13.128 17.0485 15.2821 16.4947C15.6261 17.1826 16.0293 17.8269 16.4892 18.4469L16.805 18.8725L17.3116 18.717C19.3056 18.105 21.1876 17.1751 22.8559 15.9238L23.1224 15.724L23.1528 15.3923C23.5873 10.6524 22.3579 6.53306 19.8947 2.90714L19.7759 2.73227L19.5833 2.64518C18.1437 1.99439 16.6386 1.51826 15.0686 1.25995ZM16.6074 10.7755L16.6074 10.7756C16.5934 11.6409 16.0212 12.1444 15.4783 12.1444C14.9297 12.1444 14.3493 11.6173 14.3493 10.7877C14.3493 9.94885 14.9378 9.41192 15.4783 9.41192C16.0471 9.41192 16.6209 9.93851 16.6074 10.7755ZM8.49373 12.1444C7.94513 12.1444 7.36471 11.6173 7.36471 10.7877C7.36471 9.94885 7.95323 9.41192 8.49373 9.41192C9.06038 9.41192 9.63892 9.93712 9.6417 10.7815C9.62517 11.6239 9.05462 12.1444 8.49373 12.1444Z"
strokeWidth="1.5"
/>
</svg>
),
},
];

160
bun.lock
View File

@@ -4,12 +4,31 @@
"": { "": {
"name": "diplom", "name": "diplom",
"dependencies": { "dependencies": {
"@hookform/resolvers": "^5.0.1",
"@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-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/node": "^7.5.3",
"@react-router/serve": "^7.5.3", "@react-router/serve": "^7.5.3",
"axios": "^1.9.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"immer": "^10.1.1",
"isbot": "^5.1.27", "isbot": "^5.1.27",
"lucide-react": "^0.508.0",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-hook-form": "^7.56.3",
"react-router": "^7.5.3", "react-router": "^7.5.3",
"tailwind-merge": "^3.2.0",
"zod": "^3.24.4",
"zustand": "^5.0.4",
}, },
"devDependencies": { "devDependencies": {
"@react-router/dev": "^7.5.3", "@react-router/dev": "^7.5.3",
@@ -18,6 +37,7 @@
"@types/react": "^19.1.2", "@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2", "@types/react-dom": "^19.1.2",
"tailwindcss": "^4.1.4", "tailwindcss": "^4.1.4",
"tw-animate-css": "^1.2.9",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"vite": "^6.3.3", "vite": "^6.3.3",
"vite-tsconfig-paths": "^5.1.4", "vite-tsconfig-paths": "^5.1.4",
@@ -133,6 +153,16 @@
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.4", "", { "os": "win32", "cpu": "x64" }, "sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ=="], "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.4", "", { "os": "win32", "cpu": "x64" }, "sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ=="],
"@floating-ui/core": ["@floating-ui/core@1.7.0", "", { "dependencies": { "@floating-ui/utils": "^0.2.9" } }, "sha512-FRdBLykrPPA6P76GGGqlex/e7fbe0F1ykgxHYNXQsH/iTEtjMj/f9bpY5oQqbjt5VgZvgz/uKXbGuROijh3VLA=="],
"@floating-ui/dom": ["@floating-ui/dom@1.7.0", "", { "dependencies": { "@floating-ui/core": "^1.7.0", "@floating-ui/utils": "^0.2.9" } }, "sha512-lGTor4VlXcesUMh1cupTUTDoCxMb0V6bm3CnxHzQcw8Eaf1jQbgQX4i02fYgT0vJ82tb5MZ4CZk1LRGkktJCzg=="],
"@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.2", "", { "dependencies": { "@floating-ui/dom": "^1.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A=="],
"@floating-ui/utils": ["@floating-ui/utils@0.2.9", "", {}, "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg=="],
"@hookform/resolvers": ["@hookform/resolvers@5.0.1", "", { "dependencies": { "@standard-schema/utils": "^0.3.0" }, "peerDependencies": { "react-hook-form": "^7.55.0" } }, "sha512-u/+Jp83luQNx9AdyW2fIPGY6Y7NG68eN2ZW8FOJYL+M0i4s49+refdJdOp/A9n9HFQtQs3HIDHQvX3ZET2o7YA=="],
"@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.8", "", { "dependencies": { "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA=="], "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.8", "", { "dependencies": { "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA=="],
@@ -155,6 +185,78 @@
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
"@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="],
"@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-avatar": ["@radix-ui/react-avatar@1.1.9", "", { "dependencies": { "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.2", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-10tQokfvZdFvnvDkcOJPjm2pWiP8A0R4T83MoD7tb15bC/k2GU7B1YBuzJi8lNQ8V1QqhP8ocNqp27ByZaNagQ=="],
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.6", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.2", "@radix-ui/react-slot": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-PbhRFK4lIEw9ADonj48tiYWzkllz81TM7KVYyyMMw2cwHO7D5h4XKEblL8NlaRisTK3QTe6tBEhDccFUryxHBQ=="],
"@radix-ui/react-compose-refs": ["@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-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
"@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
"@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@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-portal": "1.1.8", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.2", "@radix-ui/react-slot": "1.2.2", "@radix-ui/react-use-controllable-state": "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-ARFmqUyhIVS3+riWzwGTe7JLjqwqgnODBUZdqpWar/z1WFs9z76fuOs/2BOWCR+YboRn4/WN9aoaGVwqNRr8VA=="],
"@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="],
"@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.9", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.2", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "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-way197PiTvNp+WBP7svMJasHl+vibhWGQDb6Mgf5mhEWJkgb85z7Lfl9TUdkqpWsf8GRNmoopx9ZxCyDzmgRMQ=="],
"@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-menu": "2.1.14", "@radix-ui/react-primitive": "2.1.2", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-lzuyNjoWOoaMFE/VC5FnAAYM16JmQA8ZmucOXtlhm2kKR5TSU95YLAueQ4JYuRmUJmBvSqXaVFGIfuukybwZJQ=="],
"@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA=="],
"@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.6", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.2", "@radix-ui/react-use-callback-ref": "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-r9zpYNUQY+2jWHWZGyddQLL9YHkM/XvSFHVcWs7bdVuxMAnCwTAuy6Pf47Z4nw7dYcUou1vg/VgjjrrH03VeBw=="],
"@radix-ui/react-id": ["@radix-ui/react-id@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-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="],
"@radix-ui/react-label": ["@radix-ui/react-label@2.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-S/hv1mTlgcPX2gCTJrWuTjSXf7ER3Zf7zWGtOprxhIIY93Qin3n5VgNA0Ez9AgrK/lEtlYgzLd4f5x6AVar4Yw=="],
"@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.14", "", { "dependencies": { "@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-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.2", "@radix-ui/react-roving-focus": "1.1.9", "@radix-ui/react-slot": "1.2.2", "@radix-ui/react-use-callback-ref": "1.1.1", "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-0zSiBAIFq9GSKoSH5PdEaQeRB3RnEGxC+H2P0egtnKoKKLNBH8VBHyVO6/jskhjAezhOIplyRUj7U2lds9A+Yg=="],
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.6", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.6", "@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-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "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-7iqXaOWIjDBfIG7aq8CUEeCSsQMLFdn7VEE8TaFz704DtEzpPHR7w/uuzRflvKgltqSAImgcmxQ7fFX3X7wasg=="],
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.2", "@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-hQsTUIn7p7fxCPvao/q6wpbxmCwgLrlz+nOrJgC+RwfZqWY/WN+UMqkXzrtKbPrF82P43eCTl3ekeKuyAQbFeg=="],
"@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@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-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA=="],
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.2", "", { "dependencies": { "@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-uHa+l/lKfxuDD2zjN/0peM/RhhSmRjr5YWdk/37EnSv1nJ88uvG85DPexSm8HdFQROd2VdERJ6ynXbkCFi+APw=="],
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.9", "", { "dependencies": { "@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-id": "1.1.1", "@radix-ui/react-primitive": "2.1.2", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ZzrIFnMYHHCNqSNCsuN6l7wlewBEq0O0BCSBkabJMFXVO51LRUTq71gLP1UxFvmrXElqmPjA5VX7IqC9VpazAQ=="],
"@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-separator": ["@radix-ui/react-separator@1.1.6", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Izof3lPpbCfTM7WDta+LRkz31jem890VjEvpVRoWQNKpDUMMVffuyq854XPGP1KYGWWmjmYvHvPFeocWhFCy1w=="],
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.2", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-y7TBO4xN4Y94FvcWIOIh18fM4R1A8S4q1jhoz4PNzOoHsFcN8pogcFmZrTYAm4F9VRUrWP/Mw7xSKybIeRI+CQ=="],
"@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.2", "@radix-ui/react-roving-focus": "1.1.9", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-4FiKSVoXqPP/KfzlB7lwwqoFV6EPwkrrqGp9cUYXjwDYHhvpnqq79P+EPHKcdoTE7Rl8w/+6s9rTlsfXHES9GA=="],
"@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.9", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.6", "@radix-ui/react-portal": "1.1.8", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.2", "@radix-ui/react-slot": "1.2.2", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-visually-hidden": "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-zYb+9dc9tkoN2JjBDIIPLQtk3gGyz8FMKoqYTb8EMVQ5a5hBcdHPECrsZVI4NpPAUOixhkoqg7Hj5ry5USowfA=="],
"@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="],
"@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@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-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="],
"@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "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-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="],
"@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.1", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g=="],
"@radix-ui/react-use-is-hydrated": ["@radix-ui/react-use-is-hydrated@0.1.0", "", { "dependencies": { "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA=="],
"@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-rect": ["@radix-ui/react-use-rect@1.1.1", "", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="],
"@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="],
"@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.2", "", { "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-ORCmRUbNiZIv6uV5mhFrhsIKw4UX/N3syZtyqvry61tbGm4JlgQuSn0hk5TwCARsCjkcnuRkSdCE3xfb+ADHew=="],
"@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.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/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.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=="],
@@ -203,6 +305,8 @@
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.40.2", "", { "os": "win32", "cpu": "x64" }, "sha512-bwspbWB04XJpeElvsp+DCylKfF4trJDa2Y9Go8O6A7YLX2LIKGcNK/CYImJN6ZP4DcuOHB4Utl3iCbnR62DudA=="], "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.40.2", "", { "os": "win32", "cpu": "x64" }, "sha512-bwspbWB04XJpeElvsp+DCylKfF4trJDa2Y9Go8O6A7YLX2LIKGcNK/CYImJN6ZP4DcuOHB4Utl3iCbnR62DudA=="],
"@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.5", "", { "dependencies": { "enhanced-resolve": "^5.18.1", "jiti": "^2.4.2", "lightningcss": "1.29.2", "tailwindcss": "4.1.5" } }, "sha512-CBhSWo0vLnWhXIvpD0qsPephiaUYfHUX3U9anwDaHZAeuGpTiB3XmsxPAN6qX7bFhipyGBqOa1QYQVVhkOUGxg=="],
"@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.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=="],
@@ -249,8 +353,14 @@
"arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="], "arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
"aria-hidden": ["aria-hidden@1.2.4", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-y+CcFFwelSXpLZk/7fMB2mUbGtX9lKycf1MWJ7CaTIERyitVlyQx6C+sxcROU2BAJ24OiZyK+8wj2i8AlBoS3A=="],
"array-flatten": ["array-flatten@1.1.1", "", {}, "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="], "array-flatten": ["array-flatten@1.1.1", "", {}, "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="],
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
"axios": ["axios@1.9.0", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.0", "proxy-from-env": "^1.1.0" } }, "sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg=="],
"babel-dead-code-elimination": ["babel-dead-code-elimination@1.0.10", "", { "dependencies": { "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6" } }, "sha512-DV5bdJZTzZ0zn0DC24v3jD7Mnidh6xhKa4GfKCbq3sfW8kaWhDdZjP3i81geA8T33tdYqWKw4D3fVv0CwEgKVA=="], "babel-dead-code-elimination": ["babel-dead-code-elimination@1.0.10", "", { "dependencies": { "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6" } }, "sha512-DV5bdJZTzZ0zn0DC24v3jD7Mnidh6xhKa4GfKCbq3sfW8kaWhDdZjP3i81geA8T33tdYqWKw4D3fVv0CwEgKVA=="],
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
@@ -277,10 +387,16 @@
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
"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=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
"compressible": ["compressible@2.0.18", "", { "dependencies": { "mime-db": ">= 1.43.0 < 2" } }, "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg=="], "compressible": ["compressible@2.0.18", "", { "dependencies": { "mime-db": ">= 1.43.0 < 2" } }, "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg=="],
"compression": ["compression@1.8.0", "", { "dependencies": { "bytes": "3.1.2", "compressible": "~2.0.18", "debug": "2.6.9", "negotiator": "~0.6.4", "on-headers": "~1.0.2", "safe-buffer": "5.2.1", "vary": "~1.1.2" } }, "sha512-k6WLKfunuqCYD3t6AsuPGvQWaKwuLLh2/xHNcX4qE+vIfDNXpSqnrhwA7O53R7WVQUnt8dVAIW+YHr7xTgOgGA=="], "compression": ["compression@1.8.0", "", { "dependencies": { "bytes": "3.1.2", "compressible": "~2.0.18", "debug": "2.6.9", "negotiator": "~0.6.4", "on-headers": "~1.0.2", "safe-buffer": "5.2.1", "vary": "~1.1.2" } }, "sha512-k6WLKfunuqCYD3t6AsuPGvQWaKwuLLh2/xHNcX4qE+vIfDNXpSqnrhwA7O53R7WVQUnt8dVAIW+YHr7xTgOgGA=="],
@@ -303,12 +419,16 @@
"dedent": ["dedent@1.6.0", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-F1Z+5UCFpmQUzJa11agbyPVMbpgT/qA3/SKyJ1jyBgm7dUcUEa8v9JwDkerSQXfakBwFljIxhOJqGkjUwZ9FSA=="], "dedent": ["dedent@1.6.0", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-F1Z+5UCFpmQUzJa11agbyPVMbpgT/qA3/SKyJ1jyBgm7dUcUEa8v9JwDkerSQXfakBwFljIxhOJqGkjUwZ9FSA=="],
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
"destroy": ["destroy@1.2.0", "", {}, "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg=="], "destroy": ["destroy@1.2.0", "", {}, "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg=="],
"detect-libc": ["detect-libc@2.0.4", "", {}, "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA=="], "detect-libc": ["detect-libc@2.0.4", "", {}, "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA=="],
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="],
@@ -333,6 +453,8 @@
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
"esbuild": ["esbuild@0.25.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.4", "@esbuild/android-arm": "0.25.4", "@esbuild/android-arm64": "0.25.4", "@esbuild/android-x64": "0.25.4", "@esbuild/darwin-arm64": "0.25.4", "@esbuild/darwin-x64": "0.25.4", "@esbuild/freebsd-arm64": "0.25.4", "@esbuild/freebsd-x64": "0.25.4", "@esbuild/linux-arm": "0.25.4", "@esbuild/linux-arm64": "0.25.4", "@esbuild/linux-ia32": "0.25.4", "@esbuild/linux-loong64": "0.25.4", "@esbuild/linux-mips64el": "0.25.4", "@esbuild/linux-ppc64": "0.25.4", "@esbuild/linux-riscv64": "0.25.4", "@esbuild/linux-s390x": "0.25.4", "@esbuild/linux-x64": "0.25.4", "@esbuild/netbsd-arm64": "0.25.4", "@esbuild/netbsd-x64": "0.25.4", "@esbuild/openbsd-arm64": "0.25.4", "@esbuild/openbsd-x64": "0.25.4", "@esbuild/sunos-x64": "0.25.4", "@esbuild/win32-arm64": "0.25.4", "@esbuild/win32-ia32": "0.25.4", "@esbuild/win32-x64": "0.25.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q=="], "esbuild": ["esbuild@0.25.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.4", "@esbuild/android-arm": "0.25.4", "@esbuild/android-arm64": "0.25.4", "@esbuild/android-x64": "0.25.4", "@esbuild/darwin-arm64": "0.25.4", "@esbuild/darwin-x64": "0.25.4", "@esbuild/freebsd-arm64": "0.25.4", "@esbuild/freebsd-x64": "0.25.4", "@esbuild/linux-arm": "0.25.4", "@esbuild/linux-arm64": "0.25.4", "@esbuild/linux-ia32": "0.25.4", "@esbuild/linux-loong64": "0.25.4", "@esbuild/linux-mips64el": "0.25.4", "@esbuild/linux-ppc64": "0.25.4", "@esbuild/linux-riscv64": "0.25.4", "@esbuild/linux-s390x": "0.25.4", "@esbuild/linux-x64": "0.25.4", "@esbuild/netbsd-arm64": "0.25.4", "@esbuild/netbsd-x64": "0.25.4", "@esbuild/openbsd-arm64": "0.25.4", "@esbuild/openbsd-x64": "0.25.4", "@esbuild/sunos-x64": "0.25.4", "@esbuild/win32-arm64": "0.25.4", "@esbuild/win32-ia32": "0.25.4", "@esbuild/win32-x64": "0.25.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
@@ -349,8 +471,12 @@
"finalhandler": ["finalhandler@1.3.1", "", { "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "2.4.1", "parseurl": "~1.3.3", "statuses": "2.0.1", "unpipe": "~1.0.0" } }, "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ=="], "finalhandler": ["finalhandler@1.3.1", "", { "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "2.4.1", "parseurl": "~1.3.3", "statuses": "2.0.1", "unpipe": "~1.0.0" } }, "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ=="],
"follow-redirects": ["follow-redirects@1.15.9", "", {}, "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ=="],
"foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
"form-data": ["form-data@4.0.2", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "mime-types": "^2.1.12" } }, "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w=="],
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
"fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="], "fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="],
@@ -365,6 +491,8 @@
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
"get-port": ["get-port@5.1.1", "", {}, "sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ=="], "get-port": ["get-port@5.1.1", "", {}, "sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ=="],
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
@@ -381,6 +509,8 @@
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"hosted-git-info": ["hosted-git-info@6.1.3", "", { "dependencies": { "lru-cache": "^7.5.1" } }, "sha512-HVJyzUrLIL1c0QmviVh5E8VGyUS7xCFPS6yydaVd1UegW+ibV/CohqTH9MkOLDp5o+rb82DMo77PTuc9F/8GKw=="], "hosted-git-info": ["hosted-git-info@6.1.3", "", { "dependencies": { "lru-cache": "^7.5.1" } }, "sha512-HVJyzUrLIL1c0QmviVh5E8VGyUS7xCFPS6yydaVd1UegW+ibV/CohqTH9MkOLDp5o+rb82DMo77PTuc9F/8GKw=="],
@@ -389,6 +519,8 @@
"iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="], "iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
"immer": ["immer@10.1.1", "", {}, "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
@@ -441,6 +573,8 @@
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"lucide-react": ["lucide-react@0.508.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-gcP16PnexqtOFrTtv98kVsGzTfnbPekzZiQfByi2S89xfk7E/4uKE1USZqccIp58v42LqkO7MuwpCqshwSrJCg=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="], "media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="],
@@ -513,6 +647,8 @@
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
"proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
"qs": ["qs@6.13.0", "", { "dependencies": { "side-channel": "^1.0.6" } }, "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg=="], "qs": ["qs@6.13.0", "", { "dependencies": { "side-channel": "^1.0.6" } }, "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg=="],
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
@@ -523,10 +659,18 @@
"react-dom": ["react-dom@19.1.0", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.0" } }, "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g=="], "react-dom": ["react-dom@19.1.0", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.0" } }, "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g=="],
"react-hook-form": ["react-hook-form@7.56.3", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-IK18V6GVbab4TAo1/cz3kqajxbDPGofdF0w7VHdCo0Nt8PrPlOZcuuDq9YYIV1BtjcX78x0XsldbQRQnQXWXmw=="],
"react-refresh": ["react-refresh@0.14.2", "", {}, "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA=="], "react-refresh": ["react-refresh@0.14.2", "", {}, "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA=="],
"react-remove-scroll": ["react-remove-scroll@2.6.3", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-pnAi91oOk8g8ABQKGF5/M9qxmmOPxaAnopyTHYfqYEwJhyFrbbBtHuSgtKEoH0jpcxx5o3hXqH1mNd9/Oi+8iQ=="],
"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.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-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=="],
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
"retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="], "retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="],
@@ -589,6 +733,8 @@
"strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"tailwind-merge": ["tailwind-merge@3.2.0", "", {}, "sha512-FQT/OVqCD+7edmmJpsgCsY820RTD5AkBryuG5IUqR5YQZSdj5xlH5nLgH7YPths7WsLPSpSBNneJdM8aS8aeFA=="],
"tailwindcss": ["tailwindcss@4.1.5", "", {}, "sha512-nYtSPfWGDiWgCkwQG/m+aX83XCwf62sBgg3bIlNiiOcggnS1x3uVRDAuyelBFL+vJdOPPCGElxv9DjHJjRHiVA=="], "tailwindcss": ["tailwindcss@4.1.5", "", {}, "sha512-nYtSPfWGDiWgCkwQG/m+aX83XCwf62sBgg3bIlNiiOcggnS1x3uVRDAuyelBFL+vJdOPPCGElxv9DjHJjRHiVA=="],
"tapable": ["tapable@2.2.1", "", {}, "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ=="], "tapable": ["tapable@2.2.1", "", {}, "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ=="],
@@ -599,8 +745,12 @@
"tsconfck": ["tsconfck@3.1.5", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-CLDfGgUp7XPswWnezWwsCRxNmgQjhYq3VXHM0/XIRxhVrKw0M1if9agzryh1QS3nxjCROvV+xWxoJO1YctzzWg=="], "tsconfck": ["tsconfck@3.1.5", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-CLDfGgUp7XPswWnezWwsCRxNmgQjhYq3VXHM0/XIRxhVrKw0M1if9agzryh1QS3nxjCROvV+xWxoJO1YctzzWg=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"turbo-stream": ["turbo-stream@2.4.0", "", {}, "sha512-FHncC10WpBd2eOmGwpmQsWLDoK4cqsA/UT/GqNoaKOQnT8uzhtCbg3EoUDMvqpOSAI0S26mr0rkjzbOO6S3v1g=="], "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=="], "type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="],
"typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
@@ -615,6 +765,12 @@
"update-browserslist-db": ["update-browserslist-db@1.1.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw=="], "update-browserslist-db": ["update-browserslist-db@1.1.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw=="],
"use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "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-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
"use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.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-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
"use-sync-external-store": ["use-sync-external-store@1.5.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A=="],
"utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="], "utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="],
"valibot": ["valibot@0.41.0", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-igDBb8CTYr8YTQlOKgaN9nSS0Be7z+WRuaeYqGf3Cjz3aKmSnqEmYnkfVjzIuumGqfHpa3fLIvMEAfhrpqN8ng=="], "valibot": ["valibot@0.41.0", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-igDBb8CTYr8YTQlOKgaN9nSS0Be7z+WRuaeYqGf3Cjz3aKmSnqEmYnkfVjzIuumGqfHpa3fLIvMEAfhrpqN8ng=="],
@@ -639,6 +795,10 @@
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"zod": ["zod@3.24.4", "", {}, "sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg=="],
"zustand": ["zustand@5.0.4", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-39VFTN5InDtMd28ZhjLyuTnlytDr9HfwO512Ai4I8ZABCoyAj4F1+sr7sD1jP/+p7k77Iko0Pb5NhgBFDCX0kQ=="],
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],

21
components.json Normal file
View File

@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/app.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "~/components",
"utils": "~/lib/utils",
"ui": "~/components/ui",
"lib": "~/lib",
"hooks": "~/hooks"
},
"iconLibrary": "lucide"
}

View File

@@ -9,12 +9,31 @@
"typecheck": "react-router typegen && tsc" "typecheck": "react-router typegen && tsc"
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "^5.0.1",
"@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-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/node": "^7.5.3",
"@react-router/serve": "^7.5.3", "@react-router/serve": "^7.5.3",
"axios": "^1.9.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"immer": "^10.1.1",
"isbot": "^5.1.27", "isbot": "^5.1.27",
"lucide-react": "^0.508.0",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-router": "^7.5.3" "react-hook-form": "^7.56.3",
"react-router": "^7.5.3",
"tailwind-merge": "^3.2.0",
"zod": "^3.24.4",
"zustand": "^5.0.4"
}, },
"devDependencies": { "devDependencies": {
"@react-router/dev": "^7.5.3", "@react-router/dev": "^7.5.3",
@@ -23,6 +42,7 @@
"@types/react": "^19.1.2", "@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2", "@types/react-dom": "^19.1.2",
"tailwindcss": "^4.1.4", "tailwindcss": "^4.1.4",
"tw-animate-css": "^1.2.9",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"vite": "^6.3.3", "vite": "^6.3.3",
"vite-tsconfig-paths": "^5.1.4" "vite-tsconfig-paths": "^5.1.4"

View File

@@ -3,5 +3,5 @@ import type { Config } from "@react-router/dev/config";
export default { export default {
// Config options... // Config options...
// Server-side render by default, to enable SPA mode set this to `false` // Server-side render by default, to enable SPA mode set this to `false`
ssr: true, ssr: false,
} satisfies Config; } satisfies Config;