Skip to content

Commit b9aeed7

Browse files
committed
share onboard fetching/ui in settings page
1 parent 30e2f2a commit b9aeed7

File tree

7 files changed

+445
-279
lines changed

7 files changed

+445
-279
lines changed

app/components/DiscordLayout.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useState } from "react";
2-
import { Link, useLocation } from "react-router";
2+
import { Link, useLocation, useParams } from "react-router";
33
import { useUser } from "#~/utils";
44
import { Logout } from "#~/basics/logout";
55

@@ -23,6 +23,7 @@ export function DiscordLayout({
2323
const user = useUser();
2424
const location = useLocation();
2525
const [accountExpanded, setAccountExpanded] = useState(false);
26+
const { guildId } = useParams();
2627

2728
// Filter to only show manageable guilds (where Euno is installed) in the server selector
2829
const manageableGuilds = guilds.filter((guild) => guild.hasBot);
@@ -79,7 +80,7 @@ export function DiscordLayout({
7980
{/* Settings gear at bottom */}
8081
<div className="pb-3">
8182
<Link
82-
to="/settings"
83+
to={`/app/${guildId}/settings`}
8384
className={`mx-3 flex h-12 w-12 items-center justify-center rounded-2xl transition-all duration-200 ${
8485
isActive("/settings")
8586
? "rounded-xl bg-indigo-600"

app/components/GuildSettingsForm.tsx

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import { Form } from "react-router";
2+
import type { GuildRole, ProcessedChannel } from "#~/helpers/guildData.server";
3+
4+
export function GuildSettingsForm({
5+
guildId,
6+
roles,
7+
channels,
8+
buttonText = "Complete Setup",
9+
defaultValues,
10+
}: {
11+
guildId: string;
12+
roles: GuildRole[];
13+
channels: ProcessedChannel[];
14+
buttonText?: string;
15+
defaultValues?: {
16+
moderatorRole?: string;
17+
modLogChannel?: string;
18+
restrictedRole?: string;
19+
};
20+
}) {
21+
return (
22+
<Form method="post" className="space-y-6">
23+
<input type="hidden" name="guild_id" value={guildId} />
24+
25+
<div>
26+
<label
27+
htmlFor="moderator_role"
28+
className="block text-sm font-medium text-gray-700"
29+
>
30+
Moderator Role <span className="text-red-500">*</span>
31+
</label>
32+
<div className="mt-1">
33+
<select
34+
id="moderator_role"
35+
name="moderator_role"
36+
required
37+
defaultValue={defaultValues?.moderatorRole || ""}
38+
className="block w-full appearance-none rounded-md border border-gray-300 px-3 py-2 text-black shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-indigo-500 sm:text-sm"
39+
>
40+
<option value="">Select a role...</option>
41+
{roles.map((role) => (
42+
<option key={role.id} value={role.id}>
43+
{role.name}
44+
{role.color !== 0 && (
45+
<span
46+
style={{
47+
color: `#${role.color.toString(16).padStart(6, "0")}`,
48+
}}
49+
>
50+
{" "}
51+
52+
</span>
53+
)}
54+
</option>
55+
))}
56+
</select>
57+
</div>
58+
<p className="mt-2 text-sm text-gray-500">
59+
The role that grants moderator permissions to users.
60+
</p>
61+
</div>
62+
63+
<div>
64+
<label
65+
htmlFor="mod_log_channel"
66+
className="block text-sm font-medium text-gray-700"
67+
>
68+
Mod Log Channel <span className="text-red-500">*</span>
69+
</label>
70+
<div className="mt-1">
71+
<select
72+
id="mod_log_channel"
73+
name="mod_log_channel"
74+
required
75+
defaultValue={defaultValues?.modLogChannel || ""}
76+
className="block w-full appearance-none rounded-md border border-gray-300 px-3 py-2 text-black shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-indigo-500 sm:text-sm"
77+
>
78+
<option value="">Select a channel...</option>
79+
{channels.map((item) => {
80+
if (item.type === "channel") {
81+
return (
82+
<option key={item.data.id} value={item.data.id}>
83+
#{item.data.name}
84+
</option>
85+
);
86+
} else if (
87+
item.type === "category" &&
88+
item.children &&
89+
item.children.length > 0
90+
) {
91+
return (
92+
<optgroup
93+
key={item.data.id}
94+
label={item.data.name.toUpperCase()}
95+
>
96+
{item.children.map((channel) => (
97+
<option key={channel.id} value={channel.id}>
98+
#{channel.name}
99+
</option>
100+
))}
101+
</optgroup>
102+
);
103+
}
104+
return null;
105+
})}
106+
</select>
107+
</div>
108+
<p className="mt-2 text-sm text-gray-500">
109+
The channel where moderation reports will be sent.
110+
</p>
111+
</div>
112+
113+
<div>
114+
<label
115+
htmlFor="restricted_role"
116+
className="block text-sm font-medium text-gray-700"
117+
>
118+
Restricted Role (Optional)
119+
</label>
120+
<div className="mt-1">
121+
<select
122+
id="restricted_role"
123+
name="restricted_role"
124+
defaultValue={defaultValues?.restrictedRole || ""}
125+
className="block w-full appearance-none rounded-md border border-gray-300 px-3 py-2 text-black shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-indigo-500 sm:text-sm"
126+
>
127+
<option value="">Select a role...</option>
128+
{roles.map((role) => (
129+
<option key={role.id} value={role.id}>
130+
{role.name}
131+
{role.color !== 0 && (
132+
<span
133+
style={{
134+
color: `#${role.color.toString(16).padStart(6, "0")}`,
135+
}}
136+
>
137+
{" "}
138+
139+
</span>
140+
)}
141+
</option>
142+
))}
143+
</select>
144+
</div>
145+
<p className="mt-2 text-sm text-gray-500">
146+
A role that prevents members from accessing some channels during
147+
timeouts.
148+
</p>
149+
</div>
150+
151+
{(roles.length === 0 || channels.length === 0) && (
152+
<div className="rounded-md border border-yellow-200 bg-yellow-50 p-4">
153+
<div className="flex">
154+
<div className="ml-3">
155+
<h3 className="text-sm font-medium text-yellow-800">
156+
Unable to load server data
157+
</h3>
158+
<div className="mt-2 text-sm text-yellow-700">
159+
<p>
160+
We couldn't fetch your server's roles and channels. Make sure
161+
Euno has proper permissions in your server.
162+
</p>
163+
</div>
164+
</div>
165+
</div>
166+
</div>
167+
)}
168+
169+
<div>
170+
<button
171+
type="submit"
172+
className="flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
173+
>
174+
{buttonText}
175+
</button>
176+
</div>
177+
</Form>
178+
);
179+
}

app/components/Upgrade.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
export function Upgrade({ guildId }: { guildId: string }) {
2+
return (
3+
<div className="rounded-md border border-yellow-200 bg-yellow-50 p-4 text-center">
4+
<h3 className="text-sm font-medium text-yellow-800">
5+
Want more features?
6+
</h3>
7+
<div className="mt-2 text-sm text-yellow-700">
8+
<p>
9+
Upgrade to Pro for advanced analytics, unlimited tracking, and
10+
priority support.
11+
</p>
12+
<div className="mt-3">
13+
<a
14+
href={`/upgrade?guild_id=${guildId}`}
15+
className="inline-flex items-center rounded-md border border-transparent bg-yellow-600 px-3 py-2 text-sm font-medium text-white hover:bg-yellow-700 focus:outline-none focus:ring-2 focus:ring-yellow-500 focus:ring-offset-2"
16+
>
17+
Upgrade to Pro
18+
</a>
19+
</div>
20+
</div>
21+
</div>
22+
);
23+
}

app/helpers/guildData.server.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { Routes } from "discord-api-types/v10";
2+
import { rest } from "#~/discord/api.js";
3+
import { log, trackPerformance } from "#~/helpers/observability";
4+
5+
export interface GuildRole {
6+
id: string;
7+
name: string;
8+
position: number;
9+
color: number;
10+
}
11+
12+
export interface GuildChannel {
13+
id: string;
14+
name: string;
15+
position: number;
16+
type: number;
17+
parent_id?: string | null;
18+
}
19+
20+
export interface ProcessedChannel {
21+
type: "channel" | "category";
22+
data: GuildChannel;
23+
children?: GuildChannel[];
24+
}
25+
26+
export interface GuildData {
27+
roles: GuildRole[];
28+
channels: ProcessedChannel[];
29+
}
30+
31+
export async function fetchGuildData(guildId: string): Promise<GuildData> {
32+
try {
33+
const [guildRoles, guildChannels] = await trackPerformance(
34+
"discord.fetchGuildData",
35+
() =>
36+
Promise.all([
37+
rest.get(Routes.guildRoles(guildId)) as Promise<GuildRole[]>,
38+
rest.get(Routes.guildChannels(guildId)) as Promise<GuildChannel[]>,
39+
]),
40+
);
41+
42+
const roles = guildRoles
43+
.filter((role) => role.name !== "@everyone")
44+
.sort((a, b) => b.position - a.position);
45+
46+
const categories = guildChannels
47+
.filter((channel) => channel.type === 4)
48+
.sort((a, b) => a.position - b.position);
49+
50+
const allChannels = guildChannels
51+
.filter((channel) => channel.type === 0)
52+
.sort((a, b) => a.position - b.position);
53+
54+
log("info", "guildData", "Guild data fetched successfully", {
55+
guildId,
56+
rolesCount: roles.length,
57+
channelsCount: allChannels.length,
58+
categoriesCount: categories.length,
59+
});
60+
61+
const channelsByCategory = new Map<string, GuildChannel[]>();
62+
63+
allChannels.forEach((channel) => {
64+
if (channel.parent_id) {
65+
if (!channelsByCategory.has(channel.parent_id)) {
66+
channelsByCategory.set(channel.parent_id, []);
67+
}
68+
channelsByCategory.get(channel.parent_id)!.push(channel);
69+
}
70+
});
71+
72+
const channels: ProcessedChannel[] = [
73+
...allChannels
74+
.filter((channel) => !channel.parent_id)
75+
.map((channel) => ({ type: "channel", data: channel }) as const),
76+
...categories.map((category) => {
77+
const categoryChannels = channelsByCategory.get(category.id) || [];
78+
return {
79+
type: "category",
80+
data: category,
81+
children: categoryChannels.sort((a, b) => a.position - b.position),
82+
} as const;
83+
}),
84+
];
85+
86+
return { roles, channels };
87+
} catch (error) {
88+
log("error", "guildData", "Failed to fetch guild data", {
89+
guildId,
90+
error,
91+
});
92+
return { roles: [], channels: [] };
93+
}
94+
}

app/routes.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { route, layout } from "@react-router/dev/routes";
44
export default [
55
layout("routes/__auth.tsx", [
66
route("app/:guildId/onboard", "routes/onboard.tsx"),
7+
route("app/:guildId/settings", "routes/__auth/settings.tsx"),
78
route("app/:guildId/sh", "routes/__auth/dashboard.tsx"),
89
route("app/:guildId/sh/:userId", "routes/__auth/sh-user.tsx"),
910
route("login", "routes/__auth/login.tsx"),

0 commit comments

Comments
 (0)