Sidebar and app layout structure

This commit is contained in:
2025-10-18 14:19:57 +02:00
parent 99af75ea25
commit 9483a7065b
9 changed files with 398 additions and 354 deletions
+3 -3
View File
@@ -1,7 +1,7 @@
"use client"; "use client";
import MessageBox from "@/components/MessageBox"; import MessageBox from "@/components/MessageBox";
import AppSidebar from "@/components/Sidebar"; import AppLayout from "@/components/app/AppLayout";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import useApp from "@/hooks/use-app"; import useApp from "@/hooks/use-app";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
@@ -30,7 +30,7 @@ export default function DMs() {
}, [target]); }, [target]);
return ( return (
<AppSidebar app={app}> <AppLayout app={app}>
<MessageBox <MessageBox
app={app} app={app}
channelName={target?.display_name || id} channelName={target?.display_name || id}
@@ -52,6 +52,6 @@ export default function DMs() {
); );
}} }}
/> />
</AppSidebar> </AppLayout>
); );
} }
+3 -3
View File
@@ -2,7 +2,7 @@
import ProfilePicture from "@/components/ProfilePicture"; import ProfilePicture from "@/components/ProfilePicture";
import SettingsDialog from "@/components/settings/SettingsDialog"; import SettingsDialog from "@/components/settings/SettingsDialog";
import AppSidebar from "@/components/Sidebar"; import AppLayout from "@/components/app/AppLayout";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
@@ -28,7 +28,7 @@ export default function ChatHub() {
const activeServers: Server[] = []; const activeServers: Server[] = [];
return ( return (
<AppSidebar app={app}> <AppLayout app={app}>
<div className="h-svh w-full max-h-svh flex flex-col py-5 px-5 gap-5"> <div className="h-svh w-full max-h-svh flex flex-col py-5 px-5 gap-5">
<motion.div <motion.div
initial={{ opacity: 0, y: 10 }} initial={{ opacity: 0, y: 10 }}
@@ -140,6 +140,6 @@ export default function ChatHub() {
</div> </div>
</motion.div> </motion.div>
</div> </div>
</AppSidebar> </AppLayout>
); );
} }
+3 -3
View File
@@ -3,7 +3,7 @@
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import MessageBox from "@/components/MessageBox"; import MessageBox from "@/components/MessageBox";
import AppSidebar from "@/components/Sidebar"; import AppLayout from "@/components/app/AppLayout";
import { Server as ServerType } from "@/types/types"; import { Server as ServerType } from "@/types/types";
import auth from "@/lib/auth"; import auth from "@/lib/auth";
import { useServerMessages } from "@/hooks/use-messages"; import { useServerMessages } from "@/hooks/use-messages";
@@ -35,7 +35,7 @@ export default function Server() {
); );
return ( return (
<AppSidebar app={app} server={server}> <AppLayout app={app} server={server}>
<MessageBox <MessageBox
app={app} app={app}
channelName="General" channelName="General"
@@ -52,6 +52,6 @@ export default function Server() {
); );
}} }}
/> />
</AppSidebar> </AppLayout>
); );
} }
-345
View File
@@ -1,345 +0,0 @@
"use client";
import {
HashIcon,
MessageCircle,
Plus,
Search,
Volume2Icon,
} from "lucide-react";
import { Server, Channel, Message } from "@/types/types";
import React, { useEffect, useMemo, useState } from "react";
import Cookies from "js-cookie";
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "./ui/dialog";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
import { Button } from "./ui/button";
import { Card } from "./ui/card";
import { ScrollArea } from "./ui/scroll-area";
import { useRouter } from "next/navigation";
import { UserProfile } from "@/hooks/get-user";
import ProfilePicture from "./ProfilePicture";
import auth from "@/lib/auth";
import { toast } from "sonner";
import SettingsDialog from "./settings/SettingsDialog";
import Link from "next/link";
import { cn } from "@/lib/utils";
import App from "@/types/app";
import { ProfileSettings } from "@/types/settings";
import axios from "axios";
import { useIsMobile } from "@/hooks/is-mobile";
import { get } from "@/lib/request";
export default function AppSidebar({
children,
chatWith,
onNewMessage,
server,
app,
}: Readonly<{
onNewMessage?: (msg: Message) => void;
chatWith?: string;
children?: React.ReactNode;
server?: Server | undefined;
app: App;
}>) {
const [newServer, setNewServer] = useState("");
const [query, setQuery] = useState("");
const router = useRouter();
const isMobile = useIsMobile();
const dms = useMemo(() => {
return Array.from(
new Set(
app.messages
?.flatMap((item) => [item.from, item.channel_id])
.filter((item) => item !== app.profile?.id) || []
)
);
}, [app.messages, app.profile?.id]);
useEffect(() => {
const load = async () => {
const users = await Promise.all(dms.map((id) => app.getUserById(id)));
app.setDms(
users.filter(
(user) =>
user !== null &&
(user?.username.toLowerCase().includes(query.toLowerCase()) ||
user?.id.toLowerCase().includes(query.toLowerCase()))
) as UserProfile[]
);
};
load();
}, [dms, query]);
useEffect(() => {}, []);
useEffect(() => {
if (!app.profile?.node_address) return;
auth(
app.profile?.node_address,
app.node,
() => {},
app.addMessage,
(m) => {
if (m.from !== chatWith && m.from !== app.profile?.id)
toast(
`New message from ${app.profiles[m.from]?.display_name || m.from}`,
{
description: m.contents.slice(0, 80),
action: {
label: "View",
onClick: () => router.push(`/chat/${m.from}`),
},
position: "top-right",
}
);
if (onNewMessage) onNewMessage(m);
},
app.messages ? app.messages[app.messages.length - 1]?.id : undefined
);
app.setProfiles((prev) => {
if (app.profile) prev[app.profile.id] = app.profile;
return prev;
});
}, [chatWith, app.profile]);
const handleAddServer = async () => {
if (!newServer || Object.hasOwn(app.servers, newServer)) return;
try {
const server = (await get(`/api/server/${newServer}`)).data as Server;
app.setServers((prev) => ({
...prev,
[server.id]: server,
}));
Cookies.set("servers", Object.keys(app.servers).join(","));
setNewServer("");
} catch (e: any) {
toast.error(e);
}
};
return (
<div className="flex h-svh md:h-screen">
<div
className={cn(
"border-r transition-all duration-300 ease-in-out overflow-hidden flex",
app.sidebarOpen && isMobile ? "w-5xl md:w-md" : "w-0 md:w-md"
)}
>
<div className="w-16 flex flex-col items-center gap-4 py-4 border-r">
<Button
onClick={() => router.push(`/chat`)}
className="bg-transparent text-accent-foreground hover:text-accent"
>
<MessageCircle className="h-6 w-6" />
</Button>
{Object.values(app.servers).map(
(srv) =>
srv && (
<Link href={`/server/${srv.id}`} key={srv.id} className="">
<ProfilePicture name={srv.name} url={srv.icon_url} />
</Link>
)
)}
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="icon">
<Plus className="h-5 w-5" />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Join a server</DialogTitle>
</DialogHeader>
<div className="grid gap-4">
<div className="grid gap-3">
<Label htmlFor="id-1">Server ID</Label>
<Input
id="id-1"
name="id"
value={newServer}
onChange={(e) => setNewServer(e.target.value)}
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
/>
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<DialogClose asChild>
<Button type="button" onClick={handleAddServer}>
Join server
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
<footer className="mt-auto">
<SettingsDialog profile={app.profile as ProfileSettings} />
</footer>
</div>
{/* Right column */}
<div className="w-full flex flex-col bg-card/50">
{server ? (
<div className="p-3 border-b flex items-center gap-2">
<h2>{server.name}</h2>
</div>
) : (
<div className="p-3 border-b flex items-center">
<div className="relative w-full">
<Input
placeholder="Find or start a conversation"
className="pr-8 h-8"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<Search className="absolute right-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
</div>
</div>
)}
<ScrollArea className="flex-1">
<div className="p-2 flex flex-col gap-2">
{server ? (
<>
{server.channels.map((channel) => (
<ChannelProp key={channel.id} {...channel} />
))}
</>
) : (
<>
{app.dms.map((user) => (
<DMItem
name={user.display_name}
id={user.id}
status="online"
avatar={user.avatar_url}
key={user.id}
app={app}
/>
))}
{app.dms.length === 0 && query.trim() !== "" ? (
<div className="px-3 py-2">
<Button
variant="ghost"
className="w-full justify-start gap-2 text-sm"
onClick={async () => {
const response = await axios.get("/api/profile", {
params: { username: query },
});
const user = response.data as UserProfile;
router.push(`/chat/${user.id}`);
}}
>
<Plus className="h-4 w-4" />
Start new DM with “{query}”
</Button>
</div>
) : null}
</>
)}
</div>
</ScrollArea>
<footer className="mt-auto pb-3 pl-3 pr-3">
<DMItem
name={app.profile?.display_name || "Loading.."}
id={app.profile?.id || "me"}
avatar={app.profile?.avatar_url || ""}
status="online"
app={app}
settings
/>
</footer>
</div>
</div>
{app.sidebarOpen && isMobile ? (
<div
className="w-full h-full overflow-x-hidden"
onClick={() => app.setSidebarOpen(false)}
>
<div className="overflow-x-hidden min-w-screen flex-shrink-0">
{children}
</div>
</div>
) : (
children
)}
</div>
);
}
function DMItem({
name,
id,
avatar,
status,
settings,
app,
}: {
name: string;
id: string;
avatar: string;
status: string;
settings?: boolean;
app: App;
}) {
return (
<Link
href={settings ? "" : `/chat/${id}`}
className="p-2 flex flex-row items-center gap-2 cursor-pointer hover:bg-accent rounded-md"
>
<ProfilePicture name={name} url={avatar} />
<div className="flex flex-col">
<span className="text-sm font-medium">{name}</span>
<span className="text-xs text-muted-foreground">{status}</span>
</div>
{settings && (
<SettingsDialog
className="ml-auto w-7 h-7 p-0 flex items-center justify-center"
tab="profile"
profile={app.profile as ProfileSettings}
/>
)}
</Link>
);
}
function ChannelProp(channel: Channel) {
return (
<Card className="p-2 flex flex-row items-center gap-2 cursor-pointer hover:bg-accent">
<ChannelIcon kind={channel.kind} />
<span className="text-sm font-medium">{channel.name}</span>
</Card>
);
}
function ChannelIcon({ kind }: { kind: "text" | "voice" }) {
switch (kind) {
case "text":
return <HashIcon className="h-4 w-4" />;
case "voice":
return <Volume2Icon className="h-4 w-4" />;
default:
return null;
}
}
+101
View File
@@ -0,0 +1,101 @@
"use client";
import React, { useEffect, useMemo, useState } from "react";
import { cn } from "@/lib/utils";
import { useRouter } from "next/navigation";
import { get } from "@/lib/request";
import Cookies from "js-cookie";
import { toast } from "sonner";
import auth from "@/lib/auth";
import { useIsMobile } from "@/hooks/is-mobile";
import App from "@/types/app";
import { Server, Message } from "@/types/types";
import SidebarServers from "./SidebarServers";
import SidebarDMs from "./SidebarDMs";
import SidebarChannels from "./SidebarChannels";
export default function AppLayout({
children,
chatWith,
onNewMessage,
server,
app,
}: {
onNewMessage?: (msg: Message) => void;
chatWith?: string;
children?: React.ReactNode;
server?: Server;
app: App;
}) {
const [query, setQuery] = useState("");
const router = useRouter();
const isMobile = useIsMobile();
useEffect(() => {
if (!app.profile?.node_address) return;
auth(
app.profile.node_address,
app.node,
() => {},
app.addMessage,
(m) => {
if (m.from !== chatWith && m.from !== app.profile?.id)
toast(
`New message from ${app.profiles[m.from]?.display_name || m.from}`,
{
description: m.contents.slice(0, 80),
action: {
label: "View",
onClick: () => router.push(`/chat/${m.from}`),
},
position: "top-right",
}
);
if (onNewMessage) onNewMessage(m);
},
app.messages?.[app.messages.length - 1]?.id
);
app.setProfiles((prev) => {
if (app.profile) prev[app.profile.id] = app.profile;
return prev;
});
}, [chatWith, app.profile]);
return (
<div className="flex h-svh md:h-screen">
<div
className={cn(
"border-r transition-all duration-300 ease-in-out overflow-hidden flex",
app.sidebarOpen && isMobile ? "w-5xl md:w-md" : "w-0 md:w-md"
)}
>
{/* Left column — servers */}
<SidebarServers app={app} />
{/* Right column — DMs or server channels */}
{server ? (
<SidebarChannels server={server} />
) : (
<SidebarDMs app={app} query={query} setQuery={setQuery} />
)}
</div>
{app.sidebarOpen && isMobile ? (
<div
className="w-full h-full overflow-x-hidden"
onClick={() => app.setSidebarOpen(false)}
>
<div className="overflow-x-hidden min-w-screen flex-shrink-0">
{children}
</div>
</div>
) : (
children
)}
</div>
);
}
+43
View File
@@ -0,0 +1,43 @@
"use client";
import Link from "next/link";
import ProfilePicture from "../ProfilePicture";
import SettingsDialog from "../settings/SettingsDialog";
import App from "@/types/app";
import { ProfileSettings } from "@/types/settings";
export default function DMItem({
name,
id,
avatar,
status,
settings,
app,
}: {
name: string;
id: string;
avatar: string;
status: string;
settings?: boolean;
app: App;
}) {
return (
<Link
href={settings ? "" : `/chat/${id}`}
className="p-2 flex items-center gap-2 cursor-pointer hover:bg-accent rounded-md"
>
<ProfilePicture name={name} url={avatar} />
<div className="flex flex-col">
<span className="text-sm font-medium">{name}</span>
<span className="text-xs text-muted-foreground">{status}</span>
</div>
{settings && (
<SettingsDialog
className="ml-auto w-7 h-7 p-0 flex items-center justify-center"
tab="profile"
profile={app.profile as ProfileSettings}
/>
)}
</Link>
);
}
+42
View File
@@ -0,0 +1,42 @@
"use client";
import { ScrollArea } from "../ui/scroll-area";
import { Server, Channel } from "@/types/types";
import { Card } from "../ui/card";
import { HashIcon, Volume2Icon } from "lucide-react";
export function ChannelIcon({ kind }: { kind: "text" | "voice" }) {
switch (kind) {
case "text":
return <HashIcon className="h-4 w-4" />;
case "voice":
return <Volume2Icon className="h-4 w-4" />;
default:
return null;
}
}
export function ChannelItem({ channel }: { channel: Channel }) {
return (
<Card className="p-2 flex items-center gap-2 cursor-pointer hover:bg-accent">
<ChannelIcon kind={channel.kind} />
<span className="text-sm font-medium">{channel.name}</span>
</Card>
);
}
export default function SidebarChannels({ server }: { server: Server }) {
return (
<div className="w-full flex flex-col bg-card/50">
<div className="p-3 border-b flex items-center gap-2">
<h2>{server.name}</h2>
</div>
<ScrollArea className="flex-1">
<div className="p-2 flex flex-col gap-2">
{server.channels.map((channel: Channel) => (
<ChannelItem key={channel.id} channel={channel} />
))}
</div>
</ScrollArea>
</div>
);
}
+105
View File
@@ -0,0 +1,105 @@
"use client";
import { Input } from "../ui/input";
import { Search, Plus } from "lucide-react";
import { ScrollArea } from "../ui/scroll-area";
import { Button } from "../ui/button";
import { useRouter } from "next/navigation";
import axios from "axios";
import DMItem from "./DMItem";
import App from "@/types/app";
import { UserProfile } from "@/hooks/get-user";
import { useEffect } from "react";
export default function SidebarDMs({
app,
query,
setQuery,
}: {
app: App;
query: string;
setQuery: (v: string) => void;
}) {
const router = useRouter();
useEffect(() => {
const load = async () => {
const users = await Promise.all(
app.messages
.flatMap((m) => [m.from, m.channel_id])
.filter((id) => id !== app.profile?.id)
.map((id) => app.getUserById(id))
);
app.setDms(
users.filter(
(user) =>
user &&
(user.username.toLowerCase().includes(query.toLowerCase()) ||
user.id.toLowerCase().includes(query.toLowerCase()))
) as UserProfile[]
);
};
load();
}, [query]);
return (
<div className="w-full flex flex-col bg-card/50">
<div className="p-3 border-b flex items-center">
<div className="relative w-full">
<Input
placeholder="Find or start a conversation"
className="pr-8 h-8"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<Search className="absolute right-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
</div>
</div>
<ScrollArea className="flex-1">
<div className="p-2 flex flex-col gap-2">
{app.dms.map((user) => (
<DMItem
key={user.id}
name={user.display_name}
id={user.id}
avatar={user.avatar_url}
status="online"
app={app}
/>
))}
{app.dms.length === 0 && query.trim() !== "" && (
<div className="px-3 py-2">
<Button
variant="ghost"
className="w-full justify-start gap-2 text-sm"
onClick={async () => {
const res = await axios.get("/api/profile", {
params: { username: query },
});
const user = res.data as UserProfile;
router.push(`/chat/${user.id}`);
}}
>
<Plus className="h-4 w-4" />
Start new DM with “{query}”
</Button>
</div>
)}
</div>
</ScrollArea>
<footer className="mt-auto pb-3 px-3">
<DMItem
name={app.profile?.display_name || "Loading.."}
id={app.profile?.id || "me"}
avatar={app.profile?.avatar_url || ""}
status="online"
app={app}
settings
/>
</footer>
</div>
);
}
+98
View File
@@ -0,0 +1,98 @@
"use client";
import { Plus, MessageCircle } from "lucide-react";
import Link from "next/link";
import Cookies from "js-cookie";
import { Button } from "../ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "../ui/dialog";
import { Input } from "../ui/input";
import { Label } from "../ui/label";
import { toast } from "sonner";
import { useState } from "react";
import { get } from "@/lib/request";
import { Server } from "@/types/types";
import App from "@/types/app";
import ProfilePicture from "../ProfilePicture";
import SettingsDialog from "../settings/SettingsDialog";
import { useRouter } from "next/navigation";
export default function SidebarServers({ app }: { app: App }) {
const [newServer, setNewServer] = useState("");
const router = useRouter();
const handleAddServer = async () => {
if (!newServer || Object.hasOwn(app.servers, newServer)) return;
try {
const server = (await get(`/api/server/${newServer}`)).data as Server;
app.setServers((prev) => ({ ...prev, [server.id]: server }));
Cookies.set("servers", Object.keys(app.servers).join(","));
setNewServer("");
} catch (e: any) {
toast.error(e);
}
};
return (
<div className="w-16 flex flex-col items-center gap-4 py-4 border-r">
<Button
onClick={() => router.push(`/chat`)}
className="bg-transparent text-accent-foreground hover:text-accent"
>
<MessageCircle className="h-6 w-6" />
</Button>
{Object.values(app.servers).map(
(srv) =>
srv && (
<Link href={`/server/${srv.id}`} key={srv.id}>
<ProfilePicture name={srv.name} url={srv.icon_url} />
</Link>
)
)}
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="icon">
<Plus className="h-5 w-5" />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Join a server</DialogTitle>
</DialogHeader>
<div className="grid gap-4">
<Label htmlFor="server-id">Server ID</Label>
<Input
id="server-id"
value={newServer}
onChange={(e) => setNewServer(e.target.value)}
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
/>
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<DialogClose asChild>
<Button onClick={handleAddServer}>Join server</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
<footer className="mt-auto">
<SettingsDialog profile={app.profile!} />
</footer>
</div>
);
}