Skip to content

Commit 3d0421d

Browse files
vcarlclaude
andauthored
Add a Discord-inspired layout for logged-in pages (#158)
Why invent a new design language? The overall structure seems great to keep around. Plenty of design improvements to be made here but this let me pull together a working guild selector, which is great <img width="1256" alt="Screenshot 2025-07-03 at 6 37 23 AM" src="https://github.com/user-attachments/assets/84b8e65b-eb60-4aae-95a7-b7f02c749beb" /> --------- Co-authored-by: Claude <[email protected]>
1 parent 4b2e5fa commit 3d0421d

File tree

8 files changed

+427
-370
lines changed

8 files changed

+427
-370
lines changed

app/components/DiscordLayout.tsx

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
import { useState } from "react";
2+
import { Link, useLocation } from "react-router";
3+
import { useUser } from "#~/utils";
4+
import { Logout } from "#~/basics/logout";
5+
6+
interface DiscordLayoutProps {
7+
children: React.ReactNode;
8+
rightPanel?: React.ReactNode;
9+
guilds: Array<{
10+
id: string;
11+
name: string;
12+
icon?: string;
13+
hasBot: boolean;
14+
authz: string[];
15+
}>;
16+
}
17+
18+
export function DiscordLayout({
19+
children,
20+
rightPanel,
21+
guilds,
22+
}: DiscordLayoutProps) {
23+
const user = useUser();
24+
const location = useLocation();
25+
const [accountExpanded, setAccountExpanded] = useState(false);
26+
27+
// Filter to only show manageable guilds (where Euno is installed) in the server selector
28+
const manageableGuilds = guilds.filter((guild) => guild.hasBot);
29+
30+
const isActive = (href: string) => {
31+
return (
32+
location.pathname === href || location.pathname.startsWith(href + "/")
33+
);
34+
};
35+
36+
return (
37+
<div className="flex h-screen bg-gray-800 text-white">
38+
{/* Server Selector Column */}
39+
<div className="flex w-16 flex-col border-r border-gray-800 bg-gray-900">
40+
{/* Home/Euno Icon */}
41+
<div className="flex h-16 items-center justify-center border-b border-gray-800">
42+
<Link
43+
to="/"
44+
className="flex h-12 w-12 items-center justify-center rounded-2xl bg-indigo-600 text-lg font-bold text-white transition-all duration-200 hover:rounded-xl hover:bg-indigo-500"
45+
>
46+
E
47+
</Link>
48+
</div>
49+
50+
{/* Server Icons */}
51+
<div className="flex-1 space-y-2 overflow-y-auto py-3">
52+
{manageableGuilds.map((guild) => (
53+
<div key={guild.id} className="flex justify-center">
54+
<Link
55+
to={`/app/${guild.id}/sh`}
56+
className={`flex h-12 w-12 items-center justify-center rounded-2xl transition-all duration-200 hover:rounded-xl ${
57+
isActive(`/app/${guild.id}`)
58+
? "rounded-xl bg-indigo-600"
59+
: "bg-gray-700 hover:bg-gray-600"
60+
}`}
61+
title={guild.name}
62+
>
63+
{guild.icon ? (
64+
<img
65+
src={`https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.png?size=64`}
66+
alt={guild.name}
67+
className="h-10 w-10 rounded-xl"
68+
/>
69+
) : (
70+
<span className="font-semibold text-white">
71+
{guild.name.charAt(0).toUpperCase()}
72+
</span>
73+
)}
74+
</Link>
75+
</div>
76+
))}
77+
</div>
78+
79+
{/* Settings gear at bottom */}
80+
<div className="pb-3">
81+
<Link
82+
to="/settings"
83+
className={`mx-3 flex h-12 w-12 items-center justify-center rounded-2xl transition-all duration-200 ${
84+
isActive("/settings")
85+
? "rounded-xl bg-indigo-600"
86+
: "bg-gray-700 hover:rounded-xl hover:bg-gray-600"
87+
}`}
88+
>
89+
<span className="text-lg">⚙️</span>
90+
</Link>
91+
</div>
92+
</div>
93+
94+
{/* Channel Sidebar */}
95+
<div className="flex w-60 flex-col bg-gray-800">
96+
{/* Channel Header */}
97+
<div className="flex h-16 items-center border-b border-gray-700 px-4">
98+
<h2 className="text-lg font-semibold text-white">Euno Dashboard</h2>
99+
</div>
100+
101+
{/* Navigation */}
102+
<nav className="flex-1 space-y-1 px-3 py-4">
103+
<Link
104+
to="/"
105+
className={`group flex items-center rounded-md px-3 py-2 text-sm font-medium transition-colors ${
106+
isActive("/guilds")
107+
? "bg-gray-600 text-white"
108+
: "text-gray-300 hover:bg-gray-700 hover:text-white"
109+
}`}
110+
>
111+
<span className="mr-3 text-lg">🏠</span>
112+
Servers
113+
</Link>
114+
<Link
115+
to="/analytics"
116+
className={`group flex items-center rounded-md px-3 py-2 text-sm font-medium transition-colors ${
117+
isActive("/analytics")
118+
? "bg-gray-600 text-white"
119+
: "text-gray-300 hover:bg-gray-700 hover:text-white"
120+
}`}
121+
>
122+
<span className="mr-3 text-lg">📊</span>
123+
Analytics
124+
</Link>
125+
</nav>
126+
127+
{/* Account Section */}
128+
<div className="border-t border-gray-700 bg-gray-800">
129+
<button
130+
onClick={() => setAccountExpanded(!accountExpanded)}
131+
className="flex w-full items-center px-3 py-3 text-left text-sm transition-colors hover:bg-gray-700"
132+
>
133+
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-indigo-600 text-white">
134+
{user.email?.charAt(0).toUpperCase()}
135+
</div>
136+
<div className="ml-3 min-w-0 flex-1">
137+
<p className="truncate text-sm font-medium text-white">
138+
{user.email?.split("@")[0]}
139+
</p>
140+
<p className="truncate text-xs text-gray-400">Online</p>
141+
</div>
142+
<svg
143+
className={`h-4 w-4 text-gray-400 transition-transform ${
144+
accountExpanded ? "rotate-180" : ""
145+
}`}
146+
fill="none"
147+
stroke="currentColor"
148+
viewBox="0 0 24 24"
149+
>
150+
<path
151+
strokeLinecap="round"
152+
strokeLinejoin="round"
153+
strokeWidth={2}
154+
d="M19 9l-7 7-7-7"
155+
/>
156+
</svg>
157+
</button>
158+
159+
{/* Expanded Account Menu */}
160+
{accountExpanded && (
161+
<div className="border-t border-gray-700 bg-gray-700">
162+
<div className="px-3 py-2">
163+
<p className="mb-2 text-xs text-gray-400">Account</p>
164+
<div className="space-y-1">
165+
<Link
166+
to="/profile"
167+
className="block rounded px-2 py-1 text-sm text-gray-300 hover:bg-gray-600 hover:text-white"
168+
>
169+
Profile
170+
</Link>
171+
<div className="rounded px-2 py-1 text-sm text-gray-300 hover:bg-gray-600 hover:text-white">
172+
<Logout>Log Out</Logout>
173+
</div>
174+
</div>
175+
</div>
176+
</div>
177+
)}
178+
</div>
179+
</div>
180+
181+
{/* Main Content Area */}
182+
<div className="flex flex-1 overflow-hidden bg-gray-700">
183+
{/* Main Content */}
184+
<main className={`flex-1 overflow-auto ${rightPanel ? "pr-0" : ""}`}>
185+
<div className="h-full bg-gray-700">{children}</div>
186+
</main>
187+
188+
{/* Right Panel (conditional) */}
189+
{rightPanel && (
190+
<aside className="w-80 overflow-auto border-l border-gray-600 bg-gray-800">
191+
{rightPanel}
192+
</aside>
193+
)}
194+
</div>
195+
</div>
196+
);
197+
}

app/models/discord.server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ const processGuild = (g: APIGuild) => {
136136

137137
return {
138138
id: g.id as string,
139-
icon: g.icon,
139+
icon: g.icon ?? undefined,
140140
name: g.name as string,
141141
authz: [...authz.values()],
142142
};

app/routes.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,16 @@ import { route, layout } from "@react-router/dev/routes";
33

44
export default [
55
layout("routes/__auth.tsx", [
6-
route(":guildId/sh", "routes/__auth/dashboard.tsx"),
7-
route(":guildId/sh/:userId", "routes/__auth/sh-user.tsx"),
6+
route("app/:guildId/onboard", "routes/onboard.tsx"),
7+
route("app/:guildId/sh", "routes/__auth/dashboard.tsx"),
8+
route("app/:guildId/sh/:userId", "routes/__auth/sh-user.tsx"),
89
route("login", "routes/__auth/login.tsx"),
9-
route("test", "routes/__auth/test.tsx"),
1010
]),
1111
route("auth", "routes/auth.tsx"),
1212
route("discord-oauth", "routes/discord-oauth.tsx"),
1313
route("healthcheck", "routes/healthcheck.tsx"),
1414
route("/", "routes/index.tsx"),
1515
route("logout", "routes/logout.tsx"),
16-
route("onboard", "routes/onboard.tsx"),
17-
route("guilds", "routes/guilds.tsx"),
1816
route("upgrade", "routes/upgrade.tsx"),
1917
route("payment/success", "routes/payment.success.tsx"),
2018
route("payment/cancel", "routes/payment.cancel.tsx"),

app/routes/__auth.tsx

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,84 @@
1-
import { Outlet, useLocation } from "react-router";
1+
import { Outlet, useLocation, useLoaderData } from "react-router";
2+
import type { Route } from "./+types/__auth";
23
import { Login } from "#~/basics/login";
34
import { useOptionalUser } from "#~/utils";
5+
import { getUser, retrieveDiscordToken } from "#~/models/session.server";
6+
import { fetchGuilds } from "#~/models/discord.server";
7+
import { rest } from "#~/discord/api.js";
8+
import { REST } from "@discordjs/rest";
9+
import { log, trackPerformance } from "#~/helpers/observability";
10+
import { DiscordLayout } from "#~/components/DiscordLayout";
11+
import TTLCache from "@isaacs/ttlcache";
12+
13+
// TTL cache for guild data - 5 minute TTL, max 100 users
14+
const guildCache = new TTLCache<
15+
string,
16+
Array<{
17+
id: string;
18+
name: string;
19+
icon?: string;
20+
hasBot: boolean;
21+
authz: string[];
22+
}>
23+
>({
24+
ttl: 5 * 60 * 1000, // 5 minutes
25+
max: 100, // max 100 users cached
26+
});
27+
28+
export async function loader({ request }: Route.LoaderArgs) {
29+
const user = await getUser(request);
30+
31+
// If no user, return null - component will handle auth
32+
if (!user) {
33+
return { guilds: [] };
34+
}
35+
36+
try {
37+
// Check cache first
38+
const cachedGuilds = guildCache.get(user.id);
39+
if (cachedGuilds) {
40+
return { guilds: cachedGuilds };
41+
}
42+
43+
// Get user's Discord token for user-specific guild fetching
44+
const userToken = await retrieveDiscordToken(request);
45+
const userRest = new REST({ version: "10", authPrefix: "Bearer" }).setToken(
46+
userToken.token.access_token as string,
47+
);
48+
49+
// Fetch guilds using both user token and bot token
50+
const guilds = await trackPerformance("discord.fetchGuilds", () =>
51+
fetchGuilds(userRest, rest),
52+
);
53+
54+
// Cache the result
55+
guildCache.set(user.id, guilds);
56+
57+
log("info", "auth", "Guilds fetched for authenticated user", {
58+
userId: user.id,
59+
totalGuilds: guilds.length,
60+
manageableGuilds: guilds.filter((g) => g.hasBot).length,
61+
});
62+
63+
return { guilds };
64+
} catch (error) {
65+
log("error", "auth", "Failed to fetch guilds", { userId: user.id, error });
66+
// Return empty guilds on error - don't break auth flow
67+
return { guilds: [] };
68+
}
69+
}
470

571
export default function Auth() {
672
const user = useOptionalUser();
773
const { pathname, search, hash } = useLocation();
74+
const { guilds } = useLoaderData<typeof loader>();
75+
76+
console.log("🏠 Auth component rendering:", {
77+
hasUser: !!user,
78+
guildsCount: guilds?.length || 0,
79+
guilds,
80+
pathname,
81+
});
882

983
if (!user) {
1084
return (
@@ -16,5 +90,9 @@ export default function Auth() {
1690
);
1791
}
1892

19-
return <Outlet />;
93+
return (
94+
<DiscordLayout guilds={guilds}>
95+
<Outlet />
96+
</DiscordLayout>
97+
);
2098
}

app/routes/__auth/dashboard.tsx

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { Route } from "./+types/dashboard";
2-
import { data, useNavigation, useSearchParams, Link } from "react-router";
2+
import { data, useSearchParams, Link } from "react-router";
33
import type { LabelHTMLAttributes, PropsWithChildren } from "react";
44
import { getTopParticipants } from "#~/models/activity.server";
55

@@ -63,20 +63,15 @@ const DataHeading = ({ children }: PropsWithChildren) => {
6363
export default function DashboardPage({
6464
loaderData: data,
6565
}: Route.ComponentProps) {
66-
const nav = useNavigation();
6766
const [qs] = useSearchParams();
6867

69-
if (nav.state === "loading") {
70-
return "loading…";
71-
}
72-
7368
const start = qs.get("start") ?? undefined;
7469
const end = qs.get("end") ?? undefined;
7570

7671
if (!data) {
7772
return (
78-
<div>
79-
<div className="flex min-h-full justify-center">
73+
<div className="h-full px-6 py-8">
74+
<div className="flex justify-center">
8075
<RangeForm values={{ start, end }} />
8176
</div>
8277
<div></div>
@@ -85,8 +80,8 @@ export default function DashboardPage({
8580
}
8681

8782
return (
88-
<div>
89-
<div className="flex min-h-full justify-center">
83+
<div className="px-6 py-8">
84+
<div className="flex justify-center">
9085
<RangeForm values={{ start, end }} />
9186
</div>
9287
<div>

0 commit comments

Comments
 (0)