.
This commit is contained in:
141
app/app.css
141
app/app.css
@@ -1,15 +1,146 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme {
|
||||
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif,
|
||||
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
@apply bg-white dark:bg-gray-950;
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
color-scheme: dark;
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
|
||||
button,
|
||||
[role="button"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
/* Hide scrollbar for Chrome, Safari and Opera */
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Hide scrollbar for IE, Edge and Firefox */
|
||||
.no-scrollbar {
|
||||
/* IE and Edge */
|
||||
-ms-overflow-style: none;
|
||||
/* Firefox */
|
||||
scrollbar-width: none;
|
||||
}
|
||||
}
|
||||
122
app/components/app-layout.tsx
Normal file
122
app/components/app-layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
45
app/components/channel-list-item.tsx
Normal file
45
app/components/channel-list-item.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
113
app/components/create-server.tsx
Normal file
113
app/components/create-server.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
51
app/components/global-webrtc-audio-player.tsx
Normal file
51
app/components/global-webrtc-audio-player.tsx
Normal 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
|
||||
/>
|
||||
);
|
||||
}
|
||||
5
app/components/icons/Discord.tsx
Normal file
5
app/components/icons/Discord.tsx
Normal 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;
|
||||
@@ -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;
|
||||
}
|
||||
185
app/components/manager/webrtc-connection-manager.tsx
Normal file
185
app/components/manager/webrtc-connection-manager.tsx
Normal 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;
|
||||
}
|
||||
20
app/components/online-status.tsx
Normal file
20
app/components/online-status.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
app/components/password-input.tsx
Normal file
40
app/components/password-input.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
43
app/components/private-channel-list-item.tsx
Normal file
43
app/components/private-channel-list-item.tsx
Normal 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
125
app/components/text-box.tsx
Normal 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;
|
||||
73
app/components/theme/theme-provider.tsx
Normal file
73
app/components/theme/theme-provider.tsx
Normal 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
|
||||
}
|
||||
37
app/components/theme/theme-toggle.tsx
Normal file
37
app/components/theme/theme-toggle.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
52
app/components/ui/avatar.tsx
Normal file
52
app/components/ui/avatar.tsx
Normal 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 }
|
||||
|
||||
46
app/components/ui/badge.tsx
Normal file
46
app/components/ui/badge.tsx
Normal 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 }
|
||||
60
app/components/ui/button.tsx
Normal file
60
app/components/ui/button.tsx
Normal 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 }
|
||||
87
app/components/ui/card.tsx
Normal file
87
app/components/ui/card.tsx
Normal 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
|
||||
}
|
||||
|
||||
134
app/components/ui/dialog.tsx
Normal file
134
app/components/ui/dialog.tsx
Normal 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
|
||||
}
|
||||
|
||||
247
app/components/ui/dropdown-menu.tsx
Normal file
247
app/components/ui/dropdown-menu.tsx
Normal 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
165
app/components/ui/form.tsx
Normal 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
|
||||
}
|
||||
|
||||
121
app/components/ui/icon-upload-field.tsx
Normal file
121
app/components/ui/icon-upload-field.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
app/components/ui/input.tsx
Normal file
21
app/components/ui/input.tsx
Normal 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 }
|
||||
24
app/components/ui/label.tsx
Normal file
24
app/components/ui/label.tsx
Normal 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 }
|
||||
79
app/components/ui/scroll-area.tsx
Normal file
79
app/components/ui/scroll-area.tsx
Normal 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 }
|
||||
26
app/components/ui/separator.tsx
Normal file
26
app/components/ui/separator.tsx
Normal 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 }
|
||||
64
app/components/ui/tabs.tsx
Normal file
64
app/components/ui/tabs.tsx
Normal 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 }
|
||||
18
app/components/ui/textarea.tsx
Normal file
18
app/components/ui/textarea.tsx
Normal 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 }
|
||||
59
app/components/ui/tooltip.tsx
Normal file
59
app/components/ui/tooltip.tsx
Normal 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 }
|
||||
34
app/lib/api/client/auth.ts
Normal file
34
app/lib/api/client/auth.ts
Normal 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,
|
||||
}
|
||||
24
app/lib/api/client/server.ts
Normal file
24
app/lib/api/client/server.ts
Normal 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,
|
||||
}
|
||||
14
app/lib/api/client/test.ts
Normal file
14
app/lib/api/client/test.ts
Normal 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
|
||||
}
|
||||
19
app/lib/api/client/user.ts
Normal file
19
app/lib/api/client/user.ts
Normal 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
|
||||
}
|
||||
23
app/lib/api/http-client.ts
Normal file
23
app/lib/api/http-client.ts
Normal 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
36
app/lib/api/types.ts
Normal 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
1
app/lib/consts.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const API_URL = "http://localhost:12345/api/v1"
|
||||
14
app/lib/utils.ts
Normal file
14
app/lib/utils.ts
Normal 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('');
|
||||
}
|
||||
79
app/lib/websocket/gateway.types.ts
Normal file
79
app/lib/websocket/gateway.types.ts
Normal 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;
|
||||
33
app/lib/websocket/voice.types.ts
Normal file
33
app/lib/websocket/voice.types.ts
Normal 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 };
|
||||
14
app/root.tsx
14
app/root.tsx
@@ -7,7 +7,9 @@ import {
|
||||
ScrollRestoration,
|
||||
} from "react-router";
|
||||
|
||||
import { ThemeProvider } from "~/components/theme/theme-provider";
|
||||
import type { Route } from "./+types/root";
|
||||
|
||||
import "./app.css";
|
||||
|
||||
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 }) {
|
||||
return (
|
||||
<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) {
|
||||
let message = "Oops!";
|
||||
let details = "An unexpected error occurred.";
|
||||
|
||||
@@ -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
58
app/routes/app/index.tsx
Normal 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
43
app/routes/app/layout.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
69
app/routes/app/me/channel.tsx
Normal file
69
app/routes/app/me/channel.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
23
app/routes/app/me/index.tsx
Normal file
23
app/routes/app/me/index.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
49
app/routes/app/me/layout.tsx
Normal file
49
app/routes/app/me/layout.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
23
app/routes/app/server/channel.tsx
Normal file
23
app/routes/app/server/channel.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
23
app/routes/app/server/index.tsx
Normal file
23
app/routes/app/server/index.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
53
app/routes/app/server/layout.tsx
Normal file
53
app/routes/app/server/layout.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
11
app/routes/auth/layout.tsx
Normal file
11
app/routes/auth/layout.tsx
Normal 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
114
app/routes/auth/login.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
116
app/routes/auth/register.tsx
Normal file
116
app/routes/auth/register.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
16
app/routes/index.tsx
Normal 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 <></>;
|
||||
}
|
||||
39
app/store/active-voice-channel.ts
Normal file
39
app/store/active-voice-channel.ts
Normal 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}`);
|
||||
},
|
||||
}));
|
||||
435
app/store/gateway-websocket.ts
Normal file
435
app/store/gateway-websocket.ts
Normal 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);
|
||||
}
|
||||
14
app/store/private-channels.ts
Normal file
14
app/store/private-channels.ts
Normal 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 }),
|
||||
})
|
||||
)
|
||||
20
app/store/server-channels-list.ts
Normal file
20
app/store/server-channels-list.ts
Normal 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
22
app/store/server-list.ts
Normal 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
21
app/store/token.ts
Normal 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
14
app/store/user.ts
Normal 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 }),
|
||||
}),
|
||||
)
|
||||
408
app/store/voice-websocket.ts
Normal file
408
app/store/voice-websocket.ts
Normal 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
332
app/store/webrtc.ts
Normal 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);
|
||||
// });
|
||||
// }
|
||||
// },
|
||||
}));
|
||||
@@ -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 |
@@ -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 |
@@ -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'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
160
bun.lock
@@ -4,12 +4,31 @@
|
||||
"": {
|
||||
"name": "diplom",
|
||||
"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/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",
|
||||
"lucide-react": "^0.508.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-hook-form": "^7.56.3",
|
||||
"react-router": "^7.5.3",
|
||||
"tailwind-merge": "^3.2.0",
|
||||
"zod": "^3.24.4",
|
||||
"zustand": "^5.0.4",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@react-router/dev": "^7.5.3",
|
||||
@@ -18,6 +37,7 @@
|
||||
"@types/react": "^19.1.2",
|
||||
"@types/react-dom": "^19.1.2",
|
||||
"tailwindcss": "^4.1.4",
|
||||
"tw-animate-css": "^1.2.9",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^6.3.3",
|
||||
"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=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@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/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=="],
|
||||
|
||||
"@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/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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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-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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
|
||||
|
||||
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
|
||||
|
||||
"destroy": ["destroy@1.2.0", "", {}, "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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-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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
|
||||
|
||||
"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=="],
|
||||
@@ -381,6 +509,8 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"immer": ["immer@10.1.1", "", {}, "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw=="],
|
||||
|
||||
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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-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=="],
|
||||
|
||||
"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-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-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-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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"tailwind-merge": ["tailwind-merge@3.2.0", "", {}, "sha512-FQT/OVqCD+7edmmJpsgCsY820RTD5AkBryuG5IUqR5YQZSdj5xlH5nLgH7YPths7WsLPSpSBNneJdM8aS8aeFA=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.1.5", "", {}, "sha512-nYtSPfWGDiWgCkwQG/m+aX83XCwf62sBgg3bIlNiiOcggnS1x3uVRDAuyelBFL+vJdOPPCGElxv9DjHJjRHiVA=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"turbo-stream": ["turbo-stream@2.4.0", "", {}, "sha512-FHncC10WpBd2eOmGwpmQsWLDoK4cqsA/UT/GqNoaKOQnT8uzhtCbg3EoUDMvqpOSAI0S26mr0rkjzbOO6S3v1g=="],
|
||||
|
||||
"tw-animate-css": ["tw-animate-css@1.2.9", "", {}, "sha512-9O4k1at9pMQff9EAcCEuy1UNO43JmaPQvq+0lwza9Y0BQ6LB38NiMj+qHqjoQf40355MX+gs6wtlR6H9WsSXFg=="],
|
||||
|
||||
"type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
21
components.json
Normal file
21
components.json
Normal 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"
|
||||
}
|
||||
22
package.json
22
package.json
@@ -9,12 +9,31 @@
|
||||
"typecheck": "react-router typegen && tsc"
|
||||
},
|
||||
"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/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",
|
||||
"lucide-react": "^0.508.0",
|
||||
"react": "^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": {
|
||||
"@react-router/dev": "^7.5.3",
|
||||
@@ -23,6 +42,7 @@
|
||||
"@types/react": "^19.1.2",
|
||||
"@types/react-dom": "^19.1.2",
|
||||
"tailwindcss": "^4.1.4",
|
||||
"tw-animate-css": "^1.2.9",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^6.3.3",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
|
||||
@@ -3,5 +3,5 @@ import type { Config } from "@react-router/dev/config";
|
||||
export default {
|
||||
// Config options...
|
||||
// Server-side render by default, to enable SPA mode set this to `false`
|
||||
ssr: true,
|
||||
ssr: false,
|
||||
} satisfies Config;
|
||||
|
||||
Reference in New Issue
Block a user