This commit is contained in:
2025-06-03 11:44:51 +03:00
parent 074d6674b8
commit 8c42b7dadd
18 changed files with 6923 additions and 190 deletions

View File

@@ -10,7 +10,7 @@ import {
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "~/components/ui/dialog"; // Shadcn UI Dialog } from "~/components/ui/dialog"; // Shadcn UI Dialog
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "~/components/ui/tooltip"; // Shadcn UI Tooltip import { Tooltip, TooltipContent, TooltipTrigger } from "~/components/ui/tooltip"; // Shadcn UI Tooltip
import type { UploadedFile } from "~/lib/api/types"; // Adjust path as needed import type { UploadedFile } from "~/lib/api/types"; // Adjust path as needed
import { formatFileSize } from "~/lib/utils"; // Adjust path import { formatFileSize } from "~/lib/utils"; // Adjust path
import { FileIcon } from "./file-icon"; import { FileIcon } from "./file-icon";
@@ -28,68 +28,67 @@ export default function ChatMessageAttachment({ file }: ChatMessageAttachmentPro
function GenericFileAttachment({ file }: ChatMessageAttachmentProps) { function GenericFileAttachment({ file }: ChatMessageAttachmentProps) {
return ( return (
<TooltipProvider delayDuration={100}> <div className="flex items-center gap-3 rounded-lg border bg-card p-3 shadow-sm ">
<div className="flex items-center gap-3 rounded-lg border bg-card p-3 shadow-sm "> <div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-md">
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-md"> <FileIcon className="h-8 w-8 text-muted-foreground" contentType={file.contentType} />
<FileIcon className="h-8 w-8 text-muted-foreground" contentType={file.contentType} />
</div>
<div className="flex-1 overflow-hidden">
<p className="truncate text-sm font-medium text-card-foreground">{file.filename}</p>
<p className="text-xs text-muted-foreground">{formatFileSize(file.size)}</p>
</div>
<div className="flex items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" asChild>
<a href={file.url} target="_blank" rel="noreferrer" download={file.filename}>
<Download className="h-4 w-4" />
<span className="sr-only">Download</span>
</a>
</Button>
</TooltipTrigger>
<TooltipContent>Download</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" asChild>
<a href={file.url} target="_blank" rel="noreferrer">
<ExternalLink className="h-4 w-4" />
<span className="sr-only">Open in new tab</span>
</a>
</Button>
</TooltipTrigger>
<TooltipContent>Open in new tab</TooltipContent>
</Tooltip>
</div>
</div> </div>
</TooltipProvider> <div className="flex-1 overflow-hidden">
<p className="truncate text-sm font-medium text-card-foreground">{file.filename}</p>
<p className="text-xs text-muted-foreground">{formatFileSize(file.size)}</p>
</div>
<div className="flex items-center gap-1">
<Tooltip disableHoverableContent>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" asChild>
<a href={file.url} target="_blank" rel="noreferrer" download={file.filename}>
<Download className="h-4 w-4" />
<span className="sr-only">Download</span>
</a>
</Button>
</TooltipTrigger>
<TooltipContent>Download</TooltipContent>
</Tooltip>
<Tooltip disableHoverableContent>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" asChild>
<a href={file.url} target="_blank" rel="noreferrer">
<ExternalLink className="h-4 w-4" />
<span className="sr-only">Open in new tab</span>
</a>
</Button>
</TooltipTrigger>
<TooltipContent>Open in new tab</TooltipContent>
</Tooltip>
</div>
</div>
); );
} }
function ImageAttachment({ file }: ChatMessageAttachmentProps) { function ImageAttachment({ file }: ChatMessageAttachmentProps) {
return ( return (
<Dialog> <Dialog>
<TooltipProvider delayDuration={100}> <div className="relative w-48 sm:w-64">
<div className="group relative w-48 cursor-pointer sm:w-64"> <DialogTrigger asChild>
<DialogTrigger asChild> <AspectRatio
<AspectRatio ratio={16 / 9} className="overflow-hidden rounded-lg border bg-muted"> ratio={16 / 9}
<img className="group cursor-pointer overflow-hidden rounded-lg border bg-muted"
src={file.url} >
alt={file.filename} <img
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105" src={file.url}
/> alt={file.filename}
<div className="absolute inset-0 flex items-center justify-center bg-black/30 opacity-0 transition-opacity group-hover:opacity-100"> className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
<Maximize className="h-8 w-8 text-white" /> />
</div> <div className="absolute inset-0 flex items-center justify-center bg-black/30 opacity-0 transition-opacity group-hover:opacity-100">
</AspectRatio> <Maximize className="h-8 w-8 text-white" />
</DialogTrigger> </div>
<div className="mt-1"> </AspectRatio>
<p className="truncate text-xs text-muted-foreground"> </DialogTrigger>
{file.filename} ({formatFileSize(file.size)}) <div className="mt-1">
</p> <p className="truncate text-xs text-muted-foreground">
</div> {file.filename} ({formatFileSize(file.size)})
</p>
</div> </div>
</TooltipProvider> </div>
<DialogContent className="max-w-3xl p-0"> <DialogContent className="max-w-3xl p-0">
<DialogHeader className="p-4 pb-0"> <DialogHeader className="p-4 pb-0">

View File

@@ -3,6 +3,7 @@ import { NavLink } from "react-router";
import type { Server } from "~/lib/api/types"; import type { Server } from "~/lib/api/types";
import { cn, getFirstLetters } from "~/lib/utils"; import { cn, getFirstLetters } from "~/lib/utils";
import { Button } from "../ui/button"; import { Button } from "../ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
export interface ServerButtonProps { export interface ServerButtonProps {
server: Server; server: Server;
@@ -12,14 +13,25 @@ export function ServerButton({ server }: ServerButtonProps) {
return ( return (
<NavLink to={`/app/server/${server.id}`}> <NavLink to={`/app/server/${server.id}`}>
{({ isActive }) => ( {({ isActive }) => (
<Button variant="outline" size="none" className={cn("overflow-hidden", isActive ? "bg-accent" : "")}> <Tooltip disableHoverableContent>
<div className="flex items-center justify-center size-12"> <TooltipTrigger asChild>
<Avatar className="rounded-none"> <Button
<AvatarImage src={server.iconUrl} className="rounded-none" /> variant="outline"
<AvatarFallback>{getFirstLetters(server.name, 4)}</AvatarFallback> size="none"
</Avatar> className={cn("overflow-hidden", isActive ? "bg-accent" : "")}
</div> >
</Button> <div className="flex items-center justify-center size-12">
<Avatar className="rounded-none">
<AvatarImage src={server.iconUrl} className="rounded-none" />
<AvatarFallback>{getFirstLetters(server.name, 4)}</AvatarFallback>
</Avatar>
</div>
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p className="text-xs">{server.name}</p>
</TooltipContent>
</Tooltip>
)} )}
</NavLink> </NavLink>
); );

View File

@@ -35,7 +35,7 @@ export function WebRTCConnectionManager() {
voiceState.leaveVoiceChannel(); voiceState.leaveVoiceChannel();
unsubscribe(); unsubscribe();
}; };
}); }, []);
useEffect(() => { useEffect(() => {
if (webrtc.status === ConnectionState.DISCONNECTED) { if (webrtc.status === ConnectionState.DISCONNECTED) {

View File

@@ -7,7 +7,7 @@ import type { Uuid } from "~/lib/api/types"; // Adjust path
import { cn, formatFileSize } from "~/lib/utils"; // Adjust path import { cn, formatFileSize } from "~/lib/utils"; // Adjust path
import TextBox from "./custom-ui/text-box"; // Adjust path, assuming TextBox is in ./custom-ui/ import TextBox from "./custom-ui/text-box"; // Adjust path, assuming TextBox is in ./custom-ui/
import { Button } from "./ui/button"; // Adjust path import { Button } from "./ui/button"; // Adjust path
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip"; // Adjust path import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; // Adjust path
export interface MessageBoxProps { export interface MessageBoxProps {
channelId: string; channelId: string;
@@ -96,28 +96,26 @@ export default function MessageBox({ channelId }: MessageBoxProps) {
> >
<FileIcon contentType={file.type} className="h-7 w-7 flex-shrink-0 text-muted-foreground" /> <FileIcon contentType={file.type} className="h-7 w-7 flex-shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate font-medium text-card-foreground">{file.name}</p> <p className="font-medium text-card-foreground">{file.name}</p>
<p className="text-xs text-muted-foreground">{formatFileSize(file.size)}</p> <p className="text-xs text-muted-foreground">{formatFileSize(file.size)}</p>
</div> </div>
<TooltipProvider delayDuration={100}> <Tooltip disableHoverableContent>
<Tooltip> <TooltipTrigger asChild>
<TooltipTrigger asChild> <Button
<Button variant="ghost"
variant="ghost" size="icon"
size="icon" className="h-7 w-7 flex-shrink-0 text-muted-foreground hover:text-destructive"
className="h-7 w-7 flex-shrink-0 text-muted-foreground hover:text-destructive" onClick={() => removeAttachment(i)}
onClick={() => removeAttachment(i)} disabled={isLoading}
disabled={isLoading} aria-label={`Remove ${file.name}`}
aria-label={`Remove ${file.name}`} >
> <X className="h-4 w-4" />
<X className="h-4 w-4" /> </Button>
</Button> </TooltipTrigger>
</TooltipTrigger> <TooltipContent side="top">
<TooltipContent side="top"> <p>Remove attachment</p>
<p>Remove attachment</p> </TooltipContent>
</TooltipContent> </Tooltip>
</Tooltip>
</TooltipProvider>
</div> </div>
))} ))}
</div> </div>
@@ -133,40 +131,38 @@ export default function MessageBox({ channelId }: MessageBoxProps) {
gridTemplateRows: "auto 1fr", gridTemplateRows: "auto 1fr",
}} }}
> >
<TooltipProvider delayDuration={100}> <Tooltip disableHoverableContent>
<Tooltip> <TooltipTrigger asChild>
<TooltipTrigger asChild> <div className="self-start row-start-1 col-start-1 row-span-2 col-span-1">
<div className="self-start row-start-1 col-start-1 row-span-2 col-span-1"> <input
<input type="file"
type="file" multiple
multiple className="hidden"
className="hidden" ref={fileInputRef}
ref={fileInputRef} onChange={onFileChange}
onChange={onFileChange} accept="image/*,application/pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.zip,.rar,audio/*,video/*"
accept="image/*,application/pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.zip,.rar,audio/*,video/*" disabled={isLoading}
disabled={isLoading} />
/> <Button
<Button type="button"
type="button" size="icon"
size="icon" variant="ghost"
variant="ghost" onClick={addAttachment}
onClick={addAttachment} disabled={attachments.length >= 10 || isLoading}
disabled={attachments.length >= 10 || isLoading} aria-label="Add attachment"
aria-label="Add attachment" >
> <Paperclip className="h-5 w-5" />
<Paperclip className="h-5 w-5" /> </Button>
</Button> </div>
</div> </TooltipTrigger>
</TooltipTrigger> <TooltipContent side="top">
<TooltipContent side="top"> {attachments.length >= 10 ? (
{attachments.length >= 10 ? ( <p>Maximum 10 attachments</p>
<p>Maximum 10 attachments</p> ) : (
) : ( <p>Add attachment ({attachmentsRemaining} remaining)</p>
<p>Add attachment ({attachmentsRemaining} remaining)</p> )}
)} </TooltipContent>
</TooltipContent> </Tooltip>
</Tooltip>
</TooltipProvider>
<div className="self-center row-start-1 col-start-2 row-span-2 col-span-1"> <div className="self-center row-start-1 col-start-2 row-span-2 col-span-1">
<TextBox <TextBox

View File

@@ -12,7 +12,7 @@ export default function DeleteServerConfirmModal() {
}; };
const onConfirm = async () => { const onConfirm = async () => {
await import("~/lib/api/client/server").then((m) => m.default.delet((data as { serverId: string }).serverId)); await import("~/lib/api/client/server").then((m) => m.default.deleteServer((data as { serverId: string }).serverId));
onClose(); onClose();
}; };

View File

@@ -3,6 +3,25 @@ import { getUser } from "~/lib/api/client/user";
import type { UserId } from "~/lib/api/types"; import type { UserId } from "~/lib/api/types";
import { useUsersStore } from "~/stores/users-store"; import { useUsersStore } from "~/stores/users-store";
async function fetchCurrentUser() {
const { setCurrentUserId, addUser } = useUsersStore.getState();
const user = await import("~/lib/api/client/user").then((m) => m.default.me());
setCurrentUserId(user.id);
addUser(user);
return null;
}
export const useFetchCurrentUser = () => {
const query = useQuery({
queryKey: ["users", "@me"],
queryFn: fetchCurrentUser,
});
return query;
};
export const useFetchUser = (userId: UserId) => { export const useFetchUser = (userId: UserId) => {
const query = useQuery({ const query = useQuery({
queryKey: ["users", userId], queryKey: ["users", userId],

View File

@@ -29,7 +29,7 @@ export async function get(serverId: ServerId) {
return response.data as Server; return response.data as Server;
} }
export async function delet(serverId: ServerId) { export async function deleteServer(serverId: ServerId) {
const response = await axios.delete(`/servers/${serverId}`); const response = await axios.delete(`/servers/${serverId}`);
return response.data as Server; return response.data as Server;
@@ -76,7 +76,7 @@ export default {
list, list,
listChannels, listChannels,
get, get,
delet, deleteServer,
createChannel, createChannel,
getChannel, getChannel,
deleteChannel, deleteChannel,

View File

@@ -1 +1,3 @@
export const API_URL = "http://localhost:12345/api/v1"; export const API_URL = "http://192.168.0.133:12345/api/v1";
export const GATEWAY_URL = "ws://192.168.0.133:12345/gateway/ws";
export const VOICE_GATEWAY_URL = "ws://192.168.0.133:12345/voice/ws";

View File

@@ -0,0 +1,11 @@
import { useFetchCurrentUser } from "~/hooks/use-fetch-user";
interface WrapperProps {
children: React.ReactNode;
}
export default function CurrentUserProvider({ children }: WrapperProps) {
useFetchCurrentUser();
return <>{children}</>;
}

View File

@@ -2,7 +2,6 @@ import { useQuery } from "@tanstack/react-query";
import { Outlet } from "react-router"; import { Outlet } from "react-router";
import AppLayout from "~/components/app-layout"; import AppLayout from "~/components/app-layout";
import { useServerListStore } from "~/stores/server-list-store"; import { useServerListStore } from "~/stores/server-list-store";
import { useUsersStore } from "~/stores/users-store";
async function fetchServers() { async function fetchServers() {
const { addServers } = useServerListStore.getState(); const { addServers } = useServerListStore.getState();
@@ -13,27 +12,12 @@ async function fetchServers() {
return null; return null;
} }
async function fetchCurrentUser() {
const { setCurrentUserId, addUser } = useUsersStore.getState();
const user = await import("~/lib/api/client/user").then((m) => m.default.me());
setCurrentUserId(user.id);
addUser(user);
return null;
}
export default function Layout() { export default function Layout() {
useQuery({ useQuery({
queryKey: ["servers"], queryKey: ["servers"],
queryFn: fetchServers, queryFn: fetchServers,
}); });
useQuery({
queryKey: ["users", "@me"],
queryFn: fetchCurrentUser,
});
return ( return (
<AppLayout> <AppLayout>
<Outlet /> <Outlet />

View File

@@ -4,6 +4,7 @@ import { GatewayWebSocketConnectionManager } from "~/components/manager/gateway-
import { WebRTCConnectionManager } from "~/components/manager/webrtc-connection-manager"; import { WebRTCConnectionManager } from "~/components/manager/webrtc-connection-manager";
import ModalProvider from "~/components/providers/modal-provider"; import ModalProvider from "~/components/providers/modal-provider";
import { useTokenStore } from "~/stores/token-store"; import { useTokenStore } from "~/stores/token-store";
import CurrentUserProvider from "./current-user-provider";
export async function clientLoader() { export async function clientLoader() {
const token = useTokenStore.getState().token; const token = useTokenStore.getState().token;
@@ -29,10 +30,12 @@ export default function Layout() {
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<> <>
<ModalProvider /> <CurrentUserProvider>
<GatewayWebSocketConnectionManager /> <ModalProvider />
<WebRTCConnectionManager /> <GatewayWebSocketConnectionManager />
<Outlet /> <WebRTCConnectionManager />
<Outlet />
</CurrentUserProvider>
</> </>
</QueryClientProvider> </QueryClientProvider>
); );

View File

@@ -1,6 +1,7 @@
import type { QueryClient } from "@tanstack/react-query"; import type { QueryClient } from "@tanstack/react-query";
import { create } from "zustand"; import { create } from "zustand";
import { messageSchema, type ChannelId, type Message, type MessageId, type ServerId } from "~/lib/api/types"; import { messageSchema, type ChannelId, type Message, type MessageId, type ServerId } from "~/lib/api/types";
import { GATEWAY_URL } from "~/lib/consts";
import { GatewayClient } from "~/lib/websocket/gateway/client"; import { GatewayClient } from "~/lib/websocket/gateway/client";
import { ConnectionState, EventType, type EventData, type VoiceServerUpdateEvent } from "~/lib/websocket/gateway/types"; import { ConnectionState, EventType, type EventData, type VoiceServerUpdateEvent } from "~/lib/websocket/gateway/types";
import { useChannelsVoiceStateStore } from "./channels-voice-state"; import { useChannelsVoiceStateStore } from "./channels-voice-state";
@@ -9,8 +10,6 @@ import { useServerChannelsStore } from "./server-channels-store";
import { useServerListStore } from "./server-list-store"; import { useServerListStore } from "./server-list-store";
import { useUsersStore } from "./users-store"; import { useUsersStore } from "./users-store";
const GATEWAY_URL = "ws://localhost:12345/gateway/ws";
const HANDLERS = { const HANDLERS = {
[EventType.ADD_SERVER]: (self: GatewayState, data: Extract<EventData, { type: EventType.ADD_SERVER }>["data"]) => { [EventType.ADD_SERVER]: (self: GatewayState, data: Extract<EventData, { type: EventType.ADD_SERVER }>["data"]) => {
useServerListStore.getState().addServer(data.server); useServerListStore.getState().addServer(data.server);

View File

@@ -16,7 +16,6 @@ export const usePrivateChannelsStore = create<PrivateChannelsStore>()(
set((state) => { set((state) => {
for (const channel of channels) { for (const channel of channels) {
state.channels[channel.id] = channel; state.channels[channel.id] = channel;
console.log("add channel", channel);
} }
}), }),
addChannel: (channel: RecipientChannel) => addChannel: (channel: RecipientChannel) =>

View File

@@ -1,54 +1,20 @@
import { create as batshitCreate, keyResolver } from "@yornaath/batshit";
import { create } from "zustand"; import { create } from "zustand";
import { immer } from "zustand/middleware/immer"; import { immer } from "zustand/middleware/immer";
import { getUser } from "~/lib/api/client/user";
import type { FullUser, PartialUser, UserId } from "~/lib/api/types"; import type { FullUser, PartialUser, UserId } from "~/lib/api/types";
type UsersStore = { type UsersStore = {
users: Record<UserId, PartialUser>; users: Record<UserId, PartialUser>;
currentUserId: UserId | undefined; currentUserId: UserId | undefined;
fetchUsersIfNotPresent: (userIds: UserId[]) => Promise<void>;
addUser: (user: PartialUser) => void; addUser: (user: PartialUser) => void;
removeUser: (userId: UserId) => void; removeUser: (userId: UserId) => void;
setCurrentUserId: (userId: UserId) => void; setCurrentUserId: (userId: UserId) => void;
getCurrentUser: () => FullUser | undefined; getCurrentUser: () => FullUser | undefined;
}; };
const usersFetcher = batshitCreate({
fetcher: async (userIds: UserId[]) => {
const users = [];
for (const userId of userIds) {
users.push(getUser(userId));
}
return await Promise.all(users);
},
resolver: keyResolver("id"),
});
export const useUsersStore = create<UsersStore>()( export const useUsersStore = create<UsersStore>()(
immer((set, get) => ({ immer((set, get) => ({
users: {}, users: {},
currentUserId: undefined, currentUserId: undefined,
fetchUsersIfNotPresent: async (userIds) => {
const userPromises: Promise<PartialUser>[] = [];
for (const userId of userIds) {
const user = get().users[userId];
if (!user) {
userPromises.push(usersFetcher.fetch(userId));
}
}
const users = await Promise.all(userPromises);
const activeUsers = users.filter(Boolean);
set((state) => {
for (const user of activeUsers) {
if (user?.id) state.users[user.id] = user;
}
});
},
addUser: (user) => addUser: (user) =>
set((state) => { set((state) => {
if (user.id !== get().currentUserId) state.users[user.id] = user; if (user.id !== get().currentUserId) state.users[user.id] = user;
@@ -68,6 +34,9 @@ export const useUsersStore = create<UsersStore>()(
state.currentUserId = userId; state.currentUserId = userId;
}), }),
getCurrentUser: () => (get().currentUserId ? (get().users[get().currentUserId!] as FullUser) : undefined), getCurrentUser: () => {
const currentUserId = get().currentUserId;
return currentUserId ? (get().users[currentUserId] as FullUser) : undefined;
},
})), })),
); );

View File

@@ -1,9 +1,9 @@
import { create } from "zustand"; import { create } from "zustand";
import { VOICE_GATEWAY_URL } from "~/lib/consts";
import { WebRTCClient } from "~/lib/websocket/voice/client"; import { WebRTCClient } from "~/lib/websocket/voice/client";
import { ConnectionState } from "~/lib/websocket/voice/types"; import { ConnectionState } from "~/lib/websocket/voice/types";
import { useVoiceStateStore } from "./voice-state-store"; import { useVoiceStateStore } from "./voice-state-store";
const VOICE_GATEWAY_URL = "ws://localhost:12345/voice/ws";
interface WebRTCState { interface WebRTCState {
client: WebRTCClient | null; client: WebRTCClient | null;

View File

@@ -1,4 +1,4 @@
import js from "@eslint/js"; import eslint from "@eslint/js";
import pluginReact from "eslint-plugin-react"; import pluginReact from "eslint-plugin-react";
import reactCompiler from "eslint-plugin-react-compiler"; import reactCompiler from "eslint-plugin-react-compiler";
import reactHooks from "eslint-plugin-react-hooks"; import reactHooks from "eslint-plugin-react-hooks";
@@ -7,19 +7,22 @@ import globals from "globals";
import tseslint from "typescript-eslint"; import tseslint from "typescript-eslint";
export default defineConfig([ export default defineConfig([
{ eslint.configs.recommended,
files: ["**/*.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"],
plugins: { js },
extends: ["js/recommended"],
},
{ {
files: ["**/*.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"], files: ["**/*.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"],
languageOptions: { globals: globals.browser }, languageOptions: { globals: globals.browser },
}, },
...tseslint.configs.recommended, tseslint.configs.recommended,
pluginReact.configs.flat.recommended, pluginReact.configs.flat.recommended,
reactHooks.configs["recommended-latest"], reactHooks.configs["recommended-latest"],
reactCompiler.configs.recommended, reactCompiler.configs.recommended,
{
settings: {
react: {
version: "detect",
},
},
},
{ {
files: ["**/*.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"], files: ["**/*.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"],
rules: { rules: {

6737
frontend.txt Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,7 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "react-router build", "build": "react-router build",
"dev": "react-router dev", "dev": "react-router dev --host",
"start": "react-router-serve ./build/server/index.js", "start": "react-router-serve ./build/server/index.js",
"typecheck": "react-router typegen && tsc" "typecheck": "react-router typegen && tsc"
}, },