diff --git a/.gitignore b/.gitignore index 9b7c041..f6f62de 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ # React Router /.react-router/ /build/ +/.idea/ \ No newline at end of file diff --git a/app/app.css b/app/app.css index 9e56f42..9a144fc 100644 --- a/app/app.css +++ b/app/app.css @@ -8,11 +8,101 @@ "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; } +:root { + --background: oklch(1.00 0 0); + --foreground: oklch(0.32 0 0); + --card: oklch(1.00 0 0); + --card-foreground: oklch(0.32 0 0); + --popover: oklch(1.00 0 0); + --popover-foreground: oklch(0.32 0 0); + --primary: oklch(0.62 0.19 259.81); + --primary-foreground: oklch(1.00 0 0); + --secondary: oklch(0.97 0.00 264.54); + --secondary-foreground: oklch(0.45 0.03 256.80); + --muted: oklch(0.98 0.00 247.84); + --muted-foreground: oklch(0.55 0.02 264.36); + --accent: oklch(0.95 0.03 236.82); + --accent-foreground: oklch(0.38 0.14 265.52); + --destructive: oklch(0.64 0.21 25.33); + --destructive-foreground: oklch(1.00 0 0); + --border: oklch(0.93 0.01 264.53); + --input: oklch(0.93 0.01 264.53); + --ring: oklch(0.62 0.19 259.81); + --chart-1: oklch(0.62 0.19 259.81); + --chart-2: oklch(0.55 0.22 262.88); + --chart-3: oklch(0.49 0.22 264.38); + --chart-4: oklch(0.42 0.18 265.64); + --chart-5: oklch(0.38 0.14 265.52); + --sidebar: oklch(0.98 0.00 247.84); + --sidebar-foreground: oklch(0.32 0 0); + --sidebar-primary: oklch(0.62 0.19 259.81); + --sidebar-primary-foreground: oklch(1.00 0 0); + --sidebar-accent: oklch(0.95 0.03 236.82); + --sidebar-accent-foreground: oklch(0.38 0.14 265.52); + --sidebar-border: oklch(0.93 0.01 264.53); + --sidebar-ring: oklch(0.62 0.19 259.81); + --font-sans: Inter, sans-serif; + --font-serif: Source Serif 4, serif; + --font-mono: JetBrains Mono, monospace; + --radius: 0.375rem; + --shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05); + --shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05); + --shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10); + --shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10); + --shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 2px 4px -1px hsl(0 0% 0% / 0.10); + --shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 4px 6px -1px hsl(0 0% 0% / 0.10); + --shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 8px 10px -1px hsl(0 0% 0% / 0.10); + --shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25); +} + +.dark { + --background: oklch(0.20 0 0); + --foreground: oklch(0.92 0 0); + --card: oklch(0.27 0 0); + --card-foreground: oklch(0.92 0 0); + --popover: oklch(0.27 0 0); + --popover-foreground: oklch(0.92 0 0); + --primary: oklch(0.62 0.19 259.81); + --primary-foreground: oklch(1.00 0 0); + --secondary: oklch(0.27 0 0); + --secondary-foreground: oklch(0.92 0 0); + --muted: oklch(0.27 0 0); + --muted-foreground: oklch(0.72 0 0); + --accent: oklch(0.38 0.14 265.52); + --accent-foreground: oklch(0.88 0.06 254.13); + --destructive: oklch(0.64 0.21 25.33); + --destructive-foreground: oklch(1.00 0 0); + --border: oklch(0.37 0 0); + --input: oklch(0.37 0 0); + --ring: oklch(0.62 0.19 259.81); + --chart-1: oklch(0.71 0.14 254.62); + --chart-2: oklch(0.62 0.19 259.81); + --chart-3: oklch(0.55 0.22 262.88); + --chart-4: oklch(0.49 0.22 264.38); + --chart-5: oklch(0.42 0.18 265.64); + --sidebar: oklch(0.20 0 0); + --sidebar-foreground: oklch(0.92 0 0); + --sidebar-primary: oklch(0.62 0.19 259.81); + --sidebar-primary-foreground: oklch(1.00 0 0); + --sidebar-accent: oklch(0.38 0.14 265.52); + --sidebar-accent-foreground: oklch(0.88 0.06 254.13); + --sidebar-border: oklch(0.37 0 0); + --sidebar-ring: oklch(0.62 0.19 259.81); + --font-sans: Inter, sans-serif; + --font-serif: Source Serif 4, serif; + --font-mono: JetBrains Mono, monospace; + --radius: 0.375rem; + --shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05); + --shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05); + --shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10); + --shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10); + --shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 2px 4px -1px hsl(0 0% 0% / 0.10); + --shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 4px 6px -1px hsl(0 0% 0% / 0.10); + --shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 8px 10px -1px hsl(0 0% 0% / 0.10); + --shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25); +} + @theme inline { - --radius-sm: calc(var(--radius) - 4px); - --radius-md: calc(var(--radius) - 2px); - --radius-lg: var(--radius); - --radius-xl: calc(var(--radius) + 4px); --color-background: var(--background); --color-foreground: var(--foreground); --color-card: var(--card); @@ -28,6 +118,7 @@ --color-accent: var(--accent); --color-accent-foreground: var(--accent-foreground); --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); --color-border: var(--border); --color-input: var(--input); --color-ring: var(--ring); @@ -44,78 +135,37 @@ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-border: var(--sidebar-border); --color-sidebar-ring: var(--sidebar-ring); -} -:root { - --radius: 0.625rem; - --background: oklch(1 0 0); - --foreground: oklch(0.145 0 0); - --card: oklch(1 0 0); - --card-foreground: oklch(0.145 0 0); - --popover: oklch(1 0 0); - --popover-foreground: oklch(0.145 0 0); - --primary: oklch(0.205 0 0); - --primary-foreground: oklch(0.985 0 0); - --secondary: oklch(0.97 0 0); - --secondary-foreground: oklch(0.205 0 0); - --muted: oklch(0.97 0 0); - --muted-foreground: oklch(0.556 0 0); - --accent: oklch(0.97 0 0); - --accent-foreground: oklch(0.205 0 0); - --destructive: oklch(0.577 0.245 27.325); - --border: oklch(0.922 0 0); - --input: oklch(0.922 0 0); - --ring: oklch(0.708 0 0); - --chart-1: oklch(0.646 0.222 41.116); - --chart-2: oklch(0.6 0.118 184.704); - --chart-3: oklch(0.398 0.07 227.392); - --chart-4: oklch(0.828 0.189 84.429); - --chart-5: oklch(0.769 0.188 70.08); - --sidebar: oklch(0.985 0 0); - --sidebar-foreground: oklch(0.145 0 0); - --sidebar-primary: oklch(0.205 0 0); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.97 0 0); - --sidebar-accent-foreground: oklch(0.205 0 0); - --sidebar-border: oklch(0.922 0 0); - --sidebar-ring: oklch(0.708 0 0); -} + --font-sans: var(--font-sans); + --font-mono: var(--font-mono); + --font-serif: var(--font-serif); -.dark { - --background: oklch(0.145 0 0); - --foreground: oklch(0.985 0 0); - --card: oklch(0.205 0 0); - --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.205 0 0); - --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.922 0 0); - --primary-foreground: oklch(0.205 0 0); - --secondary: oklch(0.269 0 0); - --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.269 0 0); - --muted-foreground: oklch(0.708 0 0); - --accent: oklch(0.269 0 0); - --accent-foreground: oklch(0.985 0 0); - --destructive: oklch(0.704 0.191 22.216); - --border: oklch(1 0 0 / 10%); - --input: oklch(1 0 0 / 15%); - --ring: oklch(0.556 0 0); - --chart-1: oklch(0.488 0.243 264.376); - --chart-2: oklch(0.696 0.17 162.48); - --chart-3: oklch(0.769 0.188 70.08); - --chart-4: oklch(0.627 0.265 303.9); - --chart-5: oklch(0.645 0.246 16.439); - --sidebar: oklch(0.205 0 0); - --sidebar-foreground: oklch(0.985 0 0); - --sidebar-primary: oklch(0.488 0.243 264.376); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.269 0 0); - --sidebar-accent-foreground: oklch(0.985 0 0); - --sidebar-border: oklch(1 0 0 / 10%); - --sidebar-ring: oklch(0.556 0 0); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + + --shadow-2xs: var(--shadow-2xs); + --shadow-xs: var(--shadow-xs); + --shadow-sm: var(--shadow-sm); + --shadow: var(--shadow); + --shadow-md: var(--shadow-md); + --shadow-lg: var(--shadow-lg); + --shadow-xl: var(--shadow-xl); + --shadow-2xl: var(--shadow-2xl); } @layer base { + /* width */ + ::-webkit-scrollbar { + @apply w-1 + } + + /* Handle */ + ::-webkit-scrollbar-thumb { + @apply bg-border rounded-full mr-0.5 + } + * { @apply border-border outline-ring/50; } @@ -131,6 +181,7 @@ } @layer utilities { + /* Hide scrollbar for Chrome, Safari and Opera */ .no-scrollbar::-webkit-scrollbar { display: none; diff --git a/app/components/app-layout.tsx b/app/components/app-layout.tsx index 35d15a6..5a49859 100644 --- a/app/components/app-layout.tsx +++ b/app/components/app-layout.tsx @@ -1,72 +1,41 @@ -import { Headphones, Mic, Settings } from "lucide-react"; import React from "react"; -import { NavLink } from "react-router"; -import { useShallow } from 'zustand/react/shallow'; -import { cn, getFirstLetters } from "~/lib/utils"; -import { useServerListStore } from "~/store/server-list"; -import { useUserStore } from "~/store/user"; -import { CreateServerButton } from "./create-server"; -import Discord from "./icons/Discord"; -import { OnlineStatus } from "./online-status"; -import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar"; -import { Button } from "./ui/button"; +import { useMatches } from "react-router"; +import { useShallow } from "zustand/react/shallow"; +import { useServerListStore } from "~/stores/server-list-store"; +import { CreateServerButton } from "./custom-ui/create-server-button"; +import { HomeButton } from "./custom-ui/home-button"; +import { ServerButton } from "./custom-ui/server-button"; +import UserStatus from "./custom-ui/user-status"; import { ScrollArea } from "./ui/scroll-area"; import { Separator } from "./ui/separator"; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip"; interface AppLayoutProps { - list: React.ReactNode; children: React.ReactNode; } -export default function AppLayout({ list, children }: AppLayoutProps) { - let user = useUserStore(state => state.user!) - let servers = useServerListStore(useShallow((state) => Array.from(state.servers.values()))) +export default function AppLayout({ children }: AppLayoutProps) { + let servers = useServerListStore(useShallow((state) => Object.values(state.servers))) + + const matches = useMatches(); + + let list = React.useMemo(() => { + return matches.map(match => (match.handle as { + listComponent?: React.ReactNode + })?.listComponent).reverse().find(component => !!component) + }, [matches]) return (
-
+
-
-
-
- - - - - - -
-
- {user.displayName || user.username} -
- @{user.username} -
-
-
-
- - - -
-
-
-
+
diff --git a/app/components/channel-area.tsx b/app/components/channel-area.tsx new file mode 100644 index 0000000..b1437fc --- /dev/null +++ b/app/components/channel-area.tsx @@ -0,0 +1,84 @@ +import { useInfiniteQuery, type QueryFunctionContext } from "@tanstack/react-query" +import type { Channel, MessageId } from "~/lib/api/types" +import ChatMessage from "./chat-message" +import MessageBox from "./message-box" +import VisibleTrigger from "./visible-trigger" + +interface ChannelAreaProps { + channel: Channel +} + +export default function ChannelArea( + { channel }: ChannelAreaProps +) { + const channelId = channel.id + + const fetchMessages = async ({ pageParam }: QueryFunctionContext) => { + return await import("~/lib/api/client/channel").then(m => m.default.paginatedMessages(channelId, 50, pageParam as MessageId | undefined)) + } + + + + const { + data, + error, + fetchNextPage, + hasNextPage, + isFetching, + isFetchingNextPage, + isPending, + status, + } = useInfiniteQuery({ + queryKey: ["messages", channelId], + initialPageParam: undefined, + queryFn: fetchMessages, + getNextPageParam: (lastPage) => lastPage.length < 50 ? undefined : lastPage[lastPage.length - 1]?.id, + staleTime: Infinity, + }) + + const fetchNextPageVisible = () => { + if (!isFetchingNextPage && hasNextPage) + fetchNextPage() + } + + let messageArea = null + + if (isPending) { + messageArea =
+ Loading... +
+ } else { + messageArea = <> +
+
+ { + status === "success" && data.pages.map((page, i) => ( + page.map((message) => ( +
+ +
+ )) + ) + ) + } + +
+ + } + + return ( + <> +
+
+ {channel?.name} +
+
+ {messageArea} +
+
+ +
+
+ + ); +} \ No newline at end of file diff --git a/app/components/channel-list-item.tsx b/app/components/channel-list-item.tsx deleted file mode 100644 index a2f5f58..0000000 --- a/app/components/channel-list-item.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { ChevronDown, Hash, Volume2 } from "lucide-react" -import { Button } from "./ui/button" - -interface Channel { - id: string - name: string - type: "text" | "voice" | "category" -} - -interface ChannelListItemProps { - channel: Channel -} - -export default function ChannelListItem({ channel }: ChannelListItemProps) { - if (channel.type === "category") { - return ( -
-
-
- {channel.name} - -
-
-
-
-
- ) - } - - return ( - <> - - - ) -} diff --git a/app/components/chat-message-attachment.tsx b/app/components/chat-message-attachment.tsx new file mode 100644 index 0000000..b9279ee --- /dev/null +++ b/app/components/chat-message-attachment.tsx @@ -0,0 +1,136 @@ +import { + Download, + ExternalLink, + Maximize +} from "lucide-react"; +import { AspectRatio } from "~/components/ui/aspect-ratio"; // Shadcn UI AspectRatio +import { Button } from "~/components/ui/button"; // Shadcn UI Button +import { + Dialog, + DialogClose, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "~/components/ui/dialog"; // Shadcn UI Dialog +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "~/components/ui/tooltip"; // Shadcn UI Tooltip +import type { UploadedFile } from "~/lib/api/types"; // Adjust path as needed +import { formatFileSize } from "~/lib/utils"; // Adjust path +import { FileIcon } from "./file-icon"; + +interface ChatMessageAttachmentProps { + file: UploadedFile; +} + +export default function ChatMessageAttachment({ file }: ChatMessageAttachmentProps) { + if (file.contentType.startsWith("image/")) { + return ; + } + return ; +} + +function GenericFileAttachment({ file }: ChatMessageAttachmentProps) { + return ( + +
+
+ +
+
+

+ {file.filename} +

+

+ {formatFileSize(file.size)} +

+
+
+ + + + + Download + + + + + + Open in new tab + +
+
+
+ ); +} + +function ImageAttachment({ file }: ChatMessageAttachmentProps) { + return ( + + +
+ + + {file.filename} +
+ +
+
+
+
+

+ {file.filename} ({formatFileSize(file.size)}) +

+
+
+
+ + + + {file.filename} + +
+ {file.filename} +
+ + + + + + + +
+
+ ); +} \ No newline at end of file diff --git a/app/components/chat-message.tsx b/app/components/chat-message.tsx new file mode 100644 index 0000000..7ba9a4e --- /dev/null +++ b/app/components/chat-message.tsx @@ -0,0 +1,86 @@ +import { Clock } from "lucide-react" +import React from "react" +import { useShallow } from "zustand/react/shallow" +import type { Message } from "~/lib/api/types" +import { useUsersStore } from "~/stores/users-store" +import ChatMessageAttachment from "./chat-message-attachment" +import UserAvatar from "./user-avatar" + +interface ChatMessageProps { + message: Message +} + +export default function ChatMessage( + { message }: ChatMessageProps +) { + const { user, fetchUsersIfNotPresent } = useUsersStore(useShallow(state => ({ + user: state.users[message.authorId], + fetchUsersIfNotPresent: state.fetchUsersIfNotPresent + }))) + + React.useEffect(() => { + fetchUsersIfNotPresent([message.authorId]) + }, []) + + const formatMessageDate = (date: Date) => { + const now = new Date() + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()) + const yesterday = new Date(today) + yesterday.setDate(yesterday.getDate() - 1) + + const messageDate = new Date(date.getFullYear(), date.getMonth(), date.getDate()) + + // Get localized time string + const timeString = date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false }) + const capitalize = (str: string) => str.charAt(0).toUpperCase() + str.slice(1) + + if (messageDate.getTime() === today.getTime()) { + // Use Intl.RelativeTimeFormat for localized "Today" + const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }) + return `${(capitalize(rtf.format(0, 'day')))}, ${timeString}` + } else if (messageDate.getTime() === yesterday.getTime()) { + // Use Intl.RelativeTimeFormat for localized "Yesterday" + const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }) + return `${capitalize(rtf.format(-1, 'day'))}, ${timeString}` + } else { + return date.toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + hour12: false + }) + } + } + + return ( +
+
+ +
+
+ + {user?.displayName || user?.username} + +
+ + + {formatMessageDate(message.createdAt)} + +
+
+
+
+ {message.content} +
+
+ { + message.attachments.map((file, i) => (
+ +
)) + } +
+
+
+ ) +} \ No newline at end of file diff --git a/app/components/custom-ui/channel-list-item.tsx b/app/components/custom-ui/channel-list-item.tsx new file mode 100644 index 0000000..4836611 --- /dev/null +++ b/app/components/custom-ui/channel-list-item.tsx @@ -0,0 +1,114 @@ +import { ChevronDown, Hash, Volume2 } from "lucide-react" +import React from "react" +import { NavLink } from "react-router" +import { useShallow } from "zustand/react/shallow" +import { ChannelType, type ServerChannel } from "~/lib/api/types" +import { cn } from "~/lib/utils" +import { useChannelsVoiceStateStore } from "~/stores/channels-voice-state" +import { useGatewayStore } from "~/stores/gateway-store" +import { useUsersStore } from "~/stores/users-store" +import { Button } from "../ui/button" +import UserAvatar from "../user-avatar" + +interface ChannelListItemProps { + channel: ServerChannel +} + +function ServerCategory({ channel }: ChannelListItemProps) { + return ( +
+
+
+ {channel.name} + +
+
+
+ ) +} + +function ServerVoice({ channel }: ChannelListItemProps) { + const updateVoiceState = useGatewayStore(state => state.updateVoiceState) + const channelVoiceState = useChannelsVoiceStateStore(state => state.channels[channel.id]) || {} + const userIds = Object.keys(channelVoiceState.users ?? {}) + + const { users, fetchUsersIfNotPresent } = useUsersStore(useShallow(state => ({ + users: state.users, + fetchUsersIfNotPresent: state.fetchUsersIfNotPresent + }))) + + const channelUsers = React.useMemo(() => userIds.map(userId => users[userId]).filter(Boolean), [userIds, users]) + + React.useEffect(() => { + fetchUsersIfNotPresent(userIds) + }, [userIds]) + + const onClick = () => { + updateVoiceState(channel.serverId, channel.id) + } + + return ( + <> + + {channelUsers.length > 0 && +
+ { + channelUsers + .map(user => ( +
+ + {user.displayName || user.username} +
+ )) + } +
} + + ) +} + +function ServerText({ channel }: ChannelListItemProps) { + return ( + + {({ isActive }) => ( + + ) + } + + ) +} + +export default function ServerChannelListItem({ channel }: ChannelListItemProps) { + switch (channel.type) { + case ChannelType.SERVER_CATEGORY: + return + case ChannelType.SERVER_VOICE: + return + case ChannelType.SERVER_TEXT: + return + default: + return null + } +} diff --git a/app/components/custom-ui/create-server-button.tsx b/app/components/custom-ui/create-server-button.tsx new file mode 100644 index 0000000..7dba44c --- /dev/null +++ b/app/components/custom-ui/create-server-button.tsx @@ -0,0 +1,15 @@ +import { CirclePlus } from "lucide-react"; + + +import { Button } from "~/components/ui/button"; +import { ModalType, useModalStore } from "~/stores/modal-store"; + +export function CreateServerButton() { + const onOpen = useModalStore(state => state.onOpen) + + return ( + + ) +} \ No newline at end of file diff --git a/app/components/custom-ui/home-button.tsx b/app/components/custom-ui/home-button.tsx new file mode 100644 index 0000000..bec5cc0 --- /dev/null +++ b/app/components/custom-ui/home-button.tsx @@ -0,0 +1,21 @@ +import { NavLink } from "react-router"; +import Discord from "../icons/Discord"; +import { Button } from "../ui/button"; + +export function HomeButton() { + return ( + + { + ({ isActive }) => ( + + ) + } + + ) +} \ No newline at end of file diff --git a/app/components/online-status.tsx b/app/components/custom-ui/online-status.tsx similarity index 88% rename from app/components/online-status.tsx rename to app/components/custom-ui/online-status.tsx index 88c5cee..d2d9040 100644 --- a/app/components/online-status.tsx +++ b/app/components/custom-ui/online-status.tsx @@ -9,7 +9,7 @@ export function OnlineStatus({ return (
-
+
{status === "online" && } {status === "dnd" && } {status === "idle" && } diff --git a/app/components/password-input.tsx b/app/components/custom-ui/password-input.tsx similarity index 94% rename from app/components/password-input.tsx rename to app/components/custom-ui/password-input.tsx index bfd5254..b4c6235 100644 --- a/app/components/password-input.tsx +++ b/app/components/custom-ui/password-input.tsx @@ -1,7 +1,7 @@ import { EyeIcon, EyeOffIcon } from "lucide-react" import React from "react" -import { Button } from "./ui/button" -import { Input } from "./ui/input" +import { Button } from "../ui/button" +import { Input } from "../ui/input" export function PasswordInput(props: React.ComponentProps<"input">) { const [showPassword, setShowPassword] = React.useState(false) diff --git a/app/components/custom-ui/private-channel-list-item.tsx b/app/components/custom-ui/private-channel-list-item.tsx new file mode 100644 index 0000000..b4e60e3 --- /dev/null +++ b/app/components/custom-ui/private-channel-list-item.tsx @@ -0,0 +1,48 @@ +import { Check } from "lucide-react" +import { NavLink } from "react-router" +import type { RecipientChannel } from "~/lib/api/types" +import { cn } from "~/lib/utils" +import { useUsersStore } from "~/stores/users-store" +import { Badge } from "../ui/badge" +import { Button } from "../ui/button" +import UserAvatar from "../user-avatar" +import { OnlineStatus } from "./online-status" + +interface PrivateChannelListItemProps { + channel: RecipientChannel +} + +export default function PrivateChannelListItem({ channel }: PrivateChannelListItemProps) { + const currentUserId = useUsersStore(state => state.currentUserId) + const recipients = channel.recipients.filter(recipient => recipient.id !== currentUserId); + const renderSystemBadge = recipients.some(recipient => recipient.system) && recipients.length === 1 + + return ( + <> + + { + ({ isActive }) => ( + + ) + } + + + ) +} diff --git a/app/components/custom-ui/server-button.tsx b/app/components/custom-ui/server-button.tsx new file mode 100644 index 0000000..e081d70 --- /dev/null +++ b/app/components/custom-ui/server-button.tsx @@ -0,0 +1,36 @@ +import { Avatar, AvatarFallback, AvatarImage } from "@radix-ui/react-avatar" +import { NavLink } from "react-router" +import type { Server } from "~/lib/api/types" +import { getFirstLetters } from "~/lib/utils" +import { Button } from "../ui/button" + +export interface ServerButtonProps { + server: Server +} + +export function ServerButton( + { server }: ServerButtonProps +) { + return ( + + { + ({ isActive }) => ( + + ) + } + + ) +} \ No newline at end of file diff --git a/app/components/custom-ui/settings-button.tsx b/app/components/custom-ui/settings-button.tsx new file mode 100644 index 0000000..5fdb142 --- /dev/null +++ b/app/components/custom-ui/settings-button.tsx @@ -0,0 +1,38 @@ +import { Settings } from "lucide-react"; +import { ModalType, useModalStore } from "~/stores/modal-store"; +import { useTokenStore } from "~/stores/token-store"; +import { Button } from "../ui/button"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "../ui/dropdown-menu"; + +export function SettingsButton() { + const setToken = useTokenStore(state => state.setToken) + const onOpen = useModalStore(state => state.onOpen) + + const onUpdateProfile = () => { + onOpen(ModalType.UPDATE_PROFILE) + } + + const onLogout = () => { + setToken(undefined) + window.location.reload() + } + + return ( + + + + + + + Update profile + + + + Logout + + + + ) +} \ No newline at end of file diff --git a/app/components/text-box.tsx b/app/components/custom-ui/text-box.tsx similarity index 94% rename from app/components/text-box.tsx rename to app/components/custom-ui/text-box.tsx index c4758bb..c8115fa 100644 --- a/app/components/text-box.tsx +++ b/app/components/custom-ui/text-box.tsx @@ -86,8 +86,7 @@ export const TextBox = forwardRef( return (
localRef.current?.focus()} diff --git a/app/components/custom-ui/user-status.tsx b/app/components/custom-ui/user-status.tsx new file mode 100644 index 0000000..d3ad5ee --- /dev/null +++ b/app/components/custom-ui/user-status.tsx @@ -0,0 +1,80 @@ +import { PhoneMissed, Signal } from "lucide-react"; +import { useServerChannelsStore } from "~/stores/server-channels-store"; +import { useServerListStore } from "~/stores/server-list-store"; +import { useUsersStore } from "~/stores/users-store"; +import { useVoiceStateStore } from "~/stores/voice-state-store"; +import { ThemeToggle } from "../theme/theme-toggle"; +import { Button } from "../ui/button"; +import { Separator } from "../ui/separator"; +import UserAvatar from "../user-avatar"; +import { OnlineStatus } from "./online-status"; +import { SettingsButton } from "./settings-button"; + +function VoiceStatus({ + voiceState +}: { voiceState: { serverId: string; channelId: string } }) { + // const webrtcState = useWebRTCStore(state => state.status) + + const leaveVoiceChannel = () => { + useVoiceStateStore.getState().leaveVoiceChannel() + } + + const channel = useServerChannelsStore(state => state.channels[voiceState.serverId]?.[voiceState.channelId]) + const server = useServerListStore(state => state.servers[voiceState.serverId]) + + return ( +
+
+ +
+ {channel?.name || "Unknown channel"} / {server?.name} +
+
+ + +
+ ) +} + +export default function UserStatus() { + const user = useUsersStore(state => state.getCurrentUser()!) + const voiceState = useVoiceStateStore(state => state.activeChannel) + + return ( +
+ {voiceState && <> + + + + } +
+
+ + + + +
+
+ {user?.displayName || user?.username || "Unknown user"} +
+ @{user?.username} +
+
+
+
+ + + {/* */} + {/* */} +
+
+
+
+ ) +} \ No newline at end of file diff --git a/app/components/file-icon.tsx b/app/components/file-icon.tsx new file mode 100644 index 0000000..9b7d522 --- /dev/null +++ b/app/components/file-icon.tsx @@ -0,0 +1,62 @@ +import { + FileArchive, + FileAudio, + FileQuestion, + FileText, + FileVideo, + ImageIcon, + type LucideProps, +} from "lucide-react"; + +interface FileIconProps extends LucideProps { + contentType: string; + className?: string; +} + +export function FileIcon({ contentType, className, ...props }: FileIconProps) { + const commonProps = { className: className ?? "h-5 w-5", ...props }; + + if (contentType.startsWith("image/")) { + return ; + } + if (contentType.startsWith("audio/")) { + return ; + } + if (contentType.startsWith("video/")) { + return ; + } + if (contentType === "application/pdf") { + return ; + } + if ( + contentType.startsWith("application/vnd.ms-excel") || + contentType.startsWith("application/vnd.openxmlformats-officedocument.spreadsheetml") + ) { + return ; // Could use a specific Excel icon if available/desired + } + if ( + contentType.startsWith("application/msword") || + contentType.startsWith("application/vnd.openxmlformats-officedocument.wordprocessingml") + ) { + return ; + } + if ( + contentType.startsWith("application/vnd.ms-powerpoint") || + contentType.startsWith("application/vnd.openxmlformats-officedocument.presentationml") + ) { + return ; + } + if ( + contentType === "application/zip" || + contentType === "application/x-rar-compressed" || + contentType === "application/x-7z-compressed" || + contentType === "application/gzip" || + contentType === "application/x-tar" + ) { + return ; + } + if (contentType.startsWith("text/")) { + return ; + } + return ; // Default for unknown types +} \ No newline at end of file diff --git a/app/components/global-webrtc-audio-player.tsx b/app/components/global-webrtc-audio-player.tsx deleted file mode 100644 index 73ea373..0000000 --- a/app/components/global-webrtc-audio-player.tsx +++ /dev/null @@ -1,51 +0,0 @@ -// ~/components/global-audio-player.tsx -import { useEffect, useRef } from 'react'; -import { useWebRTCStore } from '~/store/webrtc'; - -export function GlobalWebRTCAudioPlayer() { - const audioRef = useRef(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