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,7 +28,6 @@ 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} />
@@ -38,7 +37,7 @@ function GenericFileAttachment({ file }: ChatMessageAttachmentProps) {
<p className="text-xs text-muted-foreground">{formatFileSize(file.size)}</p> <p className="text-xs text-muted-foreground">{formatFileSize(file.size)}</p>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Tooltip> <Tooltip disableHoverableContent>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button variant="ghost" size="icon" asChild> <Button variant="ghost" size="icon" asChild>
<a href={file.url} target="_blank" rel="noreferrer" download={file.filename}> <a href={file.url} target="_blank" rel="noreferrer" download={file.filename}>
@@ -49,7 +48,7 @@ function GenericFileAttachment({ file }: ChatMessageAttachmentProps) {
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>Download</TooltipContent> <TooltipContent>Download</TooltipContent>
</Tooltip> </Tooltip>
<Tooltip> <Tooltip disableHoverableContent>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button variant="ghost" size="icon" asChild> <Button variant="ghost" size="icon" asChild>
<a href={file.url} target="_blank" rel="noreferrer"> <a href={file.url} target="_blank" rel="noreferrer">
@@ -62,17 +61,18 @@ function GenericFileAttachment({ file }: ChatMessageAttachmentProps) {
</Tooltip> </Tooltip>
</div> </div>
</div> </div>
</TooltipProvider>
); );
} }
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 ratio={16 / 9} className="overflow-hidden rounded-lg border bg-muted"> <AspectRatio
ratio={16 / 9}
className="group cursor-pointer overflow-hidden rounded-lg border bg-muted"
>
<img <img
src={file.url} src={file.url}
alt={file.filename} alt={file.filename}
@@ -89,7 +89,6 @@ function ImageAttachment({ file }: ChatMessageAttachmentProps) {
</p> </p>
</div> </div>
</div> </div>
</TooltipProvider>
<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,7 +13,13 @@ 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>
<TooltipTrigger asChild>
<Button
variant="outline"
size="none"
className={cn("overflow-hidden", isActive ? "bg-accent" : "")}
>
<div className="flex items-center justify-center size-12"> <div className="flex items-center justify-center size-12">
<Avatar className="rounded-none"> <Avatar className="rounded-none">
<AvatarImage src={server.iconUrl} className="rounded-none" /> <AvatarImage src={server.iconUrl} className="rounded-none" />
@@ -20,6 +27,11 @@ export function ServerButton({ server }: ServerButtonProps) {
</Avatar> </Avatar>
</div> </div>
</Button> </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,11 +96,10 @@ 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"
@@ -117,7 +116,6 @@ export default function MessageBox({ channelId }: MessageBoxProps) {
<p>Remove attachment</p> <p>Remove attachment</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider>
</div> </div>
))} ))}
</div> </div>
@@ -133,8 +131,7 @@ 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
@@ -166,7 +163,6 @@ export default function MessageBox({ channelId }: MessageBoxProps) {
)} )}
</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}>
<> <>
<CurrentUserProvider>
<ModalProvider /> <ModalProvider />
<GatewayWebSocketConnectionManager /> <GatewayWebSocketConnectionManager />
<WebRTCConnectionManager /> <WebRTCConnectionManager />
<Outlet /> <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"
}, },