feat: Finished saving functionality for dashboard

This commit is contained in:
Aron Malcher 2024-03-18 16:24:00 +01:00
parent 3c404ab5fa
commit 136468b4bd
Signed by: aronmal
GPG key ID: 816B7707426FC612
5 changed files with 499 additions and 233 deletions

11
add-test-server.http Normal file
View file

@ -0,0 +1,11 @@
POST http://localhost:3000/api/598539452343648256/config
# Content-Type: application/json
Authorization: Basic {{$dotenv DISCORD_CLIENT_ID}}:{{$dotenv DISCORD_CLIENT_SECRET}}
Origin: http://localhost:3000
###
DELETE http://localhost:3000/api/598539452343648256/config
# Content-Type: application/json
Authorization: Basic {{$dotenv DISCORD_CLIENT_ID}}:{{$dotenv DISCORD_CLIENT_SECRET}}
Origin: http://localhost:3000

View file

@ -35,6 +35,16 @@ export const splitInterval = (tpInterval: number) => {
return { targetMinute, targetHour, targetDay };
};
export const combineInterval = (
targetMinute: number,
targetHour: number,
targetDay: number,
) => {
const tpInterval = targetMinute | (targetHour << 6) | (targetDay << 11);
return tpInterval;
};
export const DayKeys = ["0", "1", "2", "3", "4", "5", "6"] as const;
export type DayKeys = (typeof DayKeys)[number];
export type Messages = Record<DayKeys, string | null>;

View file

@ -1,11 +1,16 @@
import { faToggleOff, faToggleOn } from "@fortawesome/pro-regular-svg-icons";
import { useLocation, useNavigate, useParams } from "@solidjs/router";
import { eq } from "drizzle-orm";
import { PgUpdateSetSource } from "drizzle-orm/pg-core";
import moment from "moment-timezone";
import createClient from "openapi-fetch";
import {
For,
Index,
Show,
batch,
createEffect,
createMemo,
createResource,
createSignal,
} from "solid-js";
@ -13,7 +18,11 @@ import { createStore } from "solid-js/store";
import { getRequestEvent } from "solid-js/web";
import { FontAwesomeIcon } from "~/components/FontAwesomeIcon";
import Layout from "~/components/Layout";
import db from "~/drizzle";
import { guilds } from "~/drizzle/schema";
import getAccessToken from "~/lib/accessToken";
import { combineInterval, splitInterval } from "~/lib/responseBuilders";
import { zodBigIntId } from "~/lib/zod";
import { paths } from "~/types/discord";
import "../../styles/pages/config.scss";
@ -22,26 +31,37 @@ if (typeof import.meta.env.VITE_DISCORD_BOT_TOKEN === "undefined")
const guessTZ = () => Intl.DateTimeFormat().resolvedOptions().timeZone;
const initialValue = (params: ReturnType<typeof useParams>) => ({
success: null as boolean | null,
guild: {
id: params.guildId,
name: undefined as string | undefined,
icon: undefined as string | null | undefined,
channel: "",
channels: [] as { id: string; name: string }[],
},
tzNames: [guessTZ()],
});
const getPayload = async (
id: string,
): Promise<
| { success: false; message: string }
| (ReturnType<typeof initialValue> & { success: true })
| {
success: true;
guild: {
id: string;
name: string;
icon: string | null | undefined;
tpChannelId: string;
channels: {
id: string;
name: string;
}[];
tpInterval: number;
pingableRoles: boolean;
timezone: string;
};
tzNames: string[];
}
> => {
"use server";
let guildId: bigint;
try {
guildId = zodBigIntId.parse(id);
} catch (e) {
return { success: false, message: "ID is invalid" };
}
const event = getRequestEvent();
if (!event) return { success: false, message: "No request event!" };
@ -63,7 +83,7 @@ const getPayload = async (
{
params: {
path: {
guild_id: id,
guild_id: String(guildId),
},
},
headers: {
@ -81,7 +101,7 @@ const getPayload = async (
};
}
const guild = guildsData?.find((e) => e.id === id);
const guild = guildsData?.find((e) => e.id === String(guildId));
if (!guild)
return {
@ -95,7 +115,10 @@ const getPayload = async (
"User is no MANAGE_GUILD permissions on this guild with requested id!",
};
const channels: ReturnType<typeof initialValue>["guild"]["channels"] = [];
const channels: {
id: string;
name: string;
}[] = [];
channelsData?.forEach((channel) => {
if (channel.type !== 0) return;
channels.push({
@ -104,6 +127,12 @@ const getPayload = async (
});
});
const config = await db.query.guilds
.findFirst({ where: eq(guilds.id, guildId) })
.execute();
if (!config) return { success: false, message: "No config found!" };
console.log(new URL(event.request.url).pathname, "success");
return {
@ -112,47 +141,80 @@ const getPayload = async (
id: guild.id,
name: guild.name,
icon: guild.icon,
channel: "",
tpChannelId: String(config.tpChannelId ?? ""),
channels,
tpInterval: config.tpInterval,
pingableRoles: config.tpRolesEnabled,
timezone: config.timezone,
},
tzNames: moment.tz.names(),
};
};
const saveConfig = async (
id: string,
updateValues: PgUpdateSetSource<typeof guilds>,
): Promise<{ success: false; message: string } | { success: true }> => {
"use server";
let guildId: bigint;
try {
guildId = zodBigIntId.parse(id);
} catch (e) {
return { success: false, message: "ID is invalid" };
}
const event = getRequestEvent();
if (!event) return { success: false, message: "No request event!" };
console.log({ updateValues });
await db
.update(guilds)
.set(updateValues)
.where(eq(guilds.id, guildId))
.execute();
console.log(new URL(event.request.url).pathname, "config save success");
return { success: true };
};
function config() {
const params = useParams();
const navigator = useNavigate();
const location = useLocation();
const [timezoneRef, setTimezoneRef] = createSignal<HTMLInputElement>();
const [timePlanningRef, setTimePlanningRef] =
createSignal<HTMLInputElement>();
const [tpEnabledRef, setTpEnabledRef] = createSignal<HTMLInputElement>();
const [channelRef, setChannelRef] = createSignal<HTMLSelectElement>();
const [targetMinuteRef, setTargetMinuteRef] =
createSignal<HTMLSelectElement>();
const [targetHourRef, setTargetHourRef] = createSignal<HTMLSelectElement>();
const [targetDayRef, setTargetDayRef] = createSignal<HTMLSelectElement>();
const [pingableRolesRef, setPingableRolesRef] =
createSignal<HTMLInputElement>();
const [timezone, setTimezone] = createSignal(guessTZ());
const [payload] = createResource(
const [payload, { refetch }] = createResource(
params.guildId,
async (id) => {
const payload = await getPayload(id).catch((e) => console.warn(e, id));
const payload = await getPayload(id);
if (!payload) {
console.error(location.pathname, payload);
return initialValue(params);
return undefined;
}
if (!payload.success) {
console.log(payload);
console.log(location.pathname, payload.message, "No success");
navigator("/config", { replace: false });
return initialValue(params);
return undefined;
}
return payload;
},
{
initialValue: initialValue(params),
deferStream: true,
},
);
@ -160,168 +222,337 @@ function config() {
features: {
timePlanning: {
enabled: false,
channelId: "833442323160891452",
channelId: "",
targetMinute: 0,
targetHour: 0,
targetDay: 0,
pingableRoles: false,
timezone: guessTZ(),
},
},
});
const updateValues = createMemo(() => {
const guild = payload()?.guild;
if (!guild) return {};
const data = config.features.timePlanning;
const tpInterval = combineInterval(
data.targetMinute,
data.targetHour,
data.targetDay,
);
const result: PgUpdateSetSource<typeof guilds> = {
timezone: data.timezone !== guild.timezone ? data.timezone : undefined,
tpChannelId:
data.enabled || !data.channelId
? data.channelId && data.channelId !== guild.tpChannelId
? BigInt(data.channelId)
: undefined
: null,
tpInterval:
data.enabled && data.channelId && tpInterval !== guild.tpInterval
? tpInterval
: undefined,
tpRolesEnabled:
data.enabled &&
data.channelId &&
data.pingableRoles !== guild.pingableRoles
? data.pingableRoles
: undefined,
};
return result;
});
const willUpdateValues = () =>
Object.values(updateValues()).filter((e) => typeof e !== "undefined")
.length;
createEffect(() => {
const channelId = payload().guild.channel;
setConfig("features", "timePlanning", "channelId", channelId);
const ref = channelRef();
if (!ref) return;
ref.value = channelId;
const guild = payload()?.guild;
if (!guild) return;
const channelId = guild.tpChannelId;
const pingableRoles = guild.pingableRoles;
const { targetMinute, targetHour, targetDay } = splitInterval(
guild.tpInterval,
);
const timezone = guild.timezone;
batch(() => {
setConfig("features", "timePlanning", "enabled", !!channelId);
setConfig("features", "timePlanning", "channelId", channelId);
setConfig("features", "timePlanning", "targetMinute", targetMinute);
setConfig("features", "timePlanning", "targetHour", targetHour);
setConfig("features", "timePlanning", "targetDay", targetDay);
setConfig("features", "timePlanning", "pingableRoles", pingableRoles);
setConfig("features", "timePlanning", "timezone", timezone);
});
});
createEffect(() => {
const ref = timezoneRef();
if (!ref) return;
ref.value = timezone();
if (ref) ref.value = config.features.timePlanning.timezone;
});
createEffect(() => {
const ref = timePlanningRef();
if (!ref) return;
ref.checked = config.features.timePlanning.enabled;
const ref = tpEnabledRef();
if (ref) ref.checked = config.features.timePlanning.enabled;
});
createEffect(() => {
const ref = channelRef();
if (ref) ref.value = config.features.timePlanning.channelId;
});
createEffect(() => {
const ref = targetMinuteRef();
if (ref) ref.value = String(config.features.timePlanning.targetMinute);
});
createEffect(() => {
const ref = targetHourRef();
if (ref) ref.value = String(config.features.timePlanning.targetHour);
});
createEffect(() => {
const ref = targetDayRef();
if (ref) ref.value = String(config.features.timePlanning.targetDay);
});
createEffect(() => {
const ref = pingableRolesRef();
if (!ref) return;
ref.checked = config.features.timePlanning.pingableRoles;
if (ref) ref.checked = config.features.timePlanning.pingableRoles;
});
return (
<Layout site="config">
<h3>Configure li&apos;l Judd in</h3>
<div>
<div class="group">
<h3>Configure li&apos;l Judd in</h3>
<div>
<div class="flex-row centered">
<img
class="guildpfp"
src={
payload().guild.icon
? `https://cdn.discordapp.com/icons/${payload().guild.id}/${
payload().guild.icon
}.webp?size=240`
: "https://cdn.discordapp.com/icons/1040502664506646548/bb5a51c4659cf47bdd942bb11e974da7.webp?size=240"
}
alt="Server pfp"
/>
<h1>{payload().guild.name}</h1>
<div>
<div class="flex-row centered">
<img
class="guildpfp"
src={
payload()?.guild.icon
? `https://cdn.discordapp.com/icons/${payload()?.guild.id}/${
payload()?.guild.icon
}.webp?size=240`
: "https://cdn.discordapp.com/icons/1040502664506646548/bb5a51c4659cf47bdd942bb11e974da7.webp?size=240"
}
alt="Server pfp"
/>
<h1>{payload()?.guild.name}</h1>
</div>
</div>
</div>
<section>
<h2>Guild</h2>
<p>General settings for this guild.</p>
<div class="flex-row">
<label for="timezone">Timezone for your server:</label>
<input
type="text"
list="timezones"
id="timezone"
ref={(e) => setTimezoneRef(e)}
// disabled={!tzNames().find((e) => e === timezone())}
onInput={(e) => setTimezone(e.target.value)}
/>
<datalist id="timezones">
<Index each={payload().tzNames}>
{(zone) => <option value={zone()} />}
</Index>
</datalist>
<button
disabled={guessTZ() === timezone()}
title={"Detected: " + guessTZ()}
onClick={() => setTimezone(guessTZ())}
>
Auto-detect
</button>
</div>
</section>
<section>
<h2>Features</h2>
<p>Configure the features of the bot</p>
<label for="timePlanning" class="flex-row">
<p>Time Planning </p>
<FontAwesomeIcon
icon={
config.features.timePlanning.enabled ? faToggleOn : faToggleOff
}
size="xl"
/>
</label>
<input
hidden
type="checkbox"
id="timePlanning"
ref={(e) => setTimePlanningRef(e)}
onInput={(e) =>
setConfig("features", "timePlanning", "enabled", e.target.checked)
}
/>
<div
class="sub"
classList={{ disabled: !config.features.timePlanning.enabled }}
>
<section class="box">
<h2>Guild</h2>
<p>General settings for this guild.</p>
<div class="flex-row">
<label>Target channel:</label>
<select
ref={(e) => setChannelRef(e)}
<label for="timezone">Timezone for your server:</label>
<input
type="text"
list="timezones"
id="timezone"
ref={(e) => setTimezoneRef(e)}
onInput={(e) =>
setConfig(
"features",
"timePlanning",
"channelId",
"timezone",
e.target.value,
)
}
>
<option disabled value="">
--Select a Channel--
</option>
<For each={payload().guild.channels}>
{(channel) => (
<option value={channel.id}>{channel.name}</option>
)}
</For>
</select>
</div>
<div class="flex-row">
<label for="pingableRoles" class="flex-row">
<p>Enable pingable Roles:</p>
<FontAwesomeIcon
icon={
config.features.timePlanning.pingableRoles
? faToggleOn
: faToggleOff
}
size="xl"
/>
</label>
<input
hidden
type="checkbox"
id="pingableRoles"
ref={(e) => setPingableRolesRef(e)}
onInput={(e) =>
setConfig(
"features",
"timePlanning",
"pingableRoles",
e.target.checked,
)
}
/>
</div>
</div>
</section>
<section>
<button>Apply</button>
<button onClick={() => navigator("/config")}>Back</button>
</section>
<datalist id="timezones">
<Index each={payload()?.tzNames}>
{(zone) => <option value={zone()} />}
</Index>
</datalist>
<button
disabled={guessTZ() === config.features.timePlanning.timezone}
title={"Detected: " + guessTZ()}
onClick={() =>
setConfig("features", "timePlanning", "timezone", guessTZ())
}
>
Auto-detect
</button>
</div>
</section>
<section class="box">
<h2>Features</h2>
<p>Configure the features of the bot</p>
<label for="timePlanning" class="flex-row">
<p>Time Planning </p>
<FontAwesomeIcon
icon={
config.features.timePlanning.enabled
? faToggleOn
: faToggleOff
}
size="xl"
/>
</label>
<input
hidden
type="checkbox"
id="timePlanning"
ref={(e) => setTpEnabledRef(e)}
onInput={(e) =>
setConfig(
"features",
"timePlanning",
"enabled",
e.target.checked,
)
}
/>
<div
class="sub"
classList={{ disabled: !config.features.timePlanning.enabled }}
>
<div class="flex-row">
<label>Target channel:</label>
<select
ref={(e) => setChannelRef(e)}
onInput={(e) =>
setConfig(
"features",
"timePlanning",
"channelId",
e.target.value,
)
}
>
<option disabled value="">
--Select a Channel--
</option>
<For each={payload()?.guild.channels}>
{(channel) => (
<option value={channel.id}>{channel.name}</option>
)}
</For>
</select>
<Show
when={
config.features.timePlanning.enabled &&
!config.features.timePlanning.channelId
}
>
{"<-- or changes won't be saved"}
</Show>
</div>
<div class="flex-row">
<label>Target Interval:</label>
<select
ref={(e) => setTargetDayRef(e)}
onInput={(e) =>
setConfig(
"features",
"timePlanning",
"targetDay",
Number(e.target.value),
)
}
>
<Index each={Array.from(Array(7)).map((_e, i) => i)}>
{(id) => {
const weekdays = [
"Sunday",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
];
return (
<option value={String(id())}>{weekdays[id()]}</option>
);
}}
</Index>
</select>
at
<select
ref={(e) => setTargetHourRef(e)}
onInput={(e) =>
setConfig(
"features",
"timePlanning",
"targetHour",
Number(e.target.value),
)
}
>
<Index each={Array.from(Array(24)).map((_e, i) => i)}>
{(id) => (
<option value={String(id())}>{String(id())}</option>
)}
</Index>
</select>
:
<select
ref={(e) => setTargetMinuteRef(e)}
onInput={(e) =>
setConfig(
"features",
"timePlanning",
"targetMinute",
Number(e.target.value),
)
}
>
<Index each={Array.from(Array(60)).map((_e, i) => i)}>
{(id) => (
<option value={String(id())}>
{String(id()).padStart(2, "0")}
</option>
)}
</Index>
</select>
</div>
<div class="flex-row">
<label for="pingableRoles" class="flex-row">
<p>Enable pingable Roles:</p>
<FontAwesomeIcon
icon={
config.features.timePlanning.pingableRoles
? faToggleOn
: faToggleOff
}
size="xl"
/>
</label>
<input
hidden
type="checkbox"
id="pingableRoles"
ref={(e) => setPingableRolesRef(e)}
onInput={(e) =>
setConfig(
"features",
"timePlanning",
"pingableRoles",
e.target.checked,
)
}
/>
</div>
</div>
</section>
<section class="box">
<button
onClick={async () => {
const id = payload()?.guild.id;
if (!id || !willUpdateValues()) return;
await saveConfig(id, updateValues());
refetch();
}}
>
Apply
</button>
<button onClick={() => navigator("/config")}>Back</button>
<Show when={willUpdateValues()}>UNSAVED CHANGES</Show>
</section>
</div>
</div>
</Layout>
);

View file

@ -1,10 +1,12 @@
import { faPlusCircle, faWrench } from "@fortawesome/pro-regular-svg-icons";
import { useLocation, useNavigate } from "@solidjs/router";
import createClient from "openapi-fetch";
import { For, createResource } from "solid-js";
import { For, Show, createResource } from "solid-js";
import { getRequestEvent } from "solid-js/web";
import { FontAwesomeIcon } from "~/components/FontAwesomeIcon";
import Layout from "~/components/Layout";
import db from "~/drizzle";
import { guilds } from "~/drizzle/schema";
import getAccessToken from "~/lib/accessToken";
import { paths } from "~/types/discord";
import "../../styles/pages/config.scss";
@ -15,13 +17,16 @@ if (typeof import.meta.env.VITE_DISCORD_CLIENT_ID === "undefined")
if (typeof import.meta.env.VITE_DISCORD_OAUTH2_PERMISSIONS === "undefined")
throw new Error("No env VITE_DISCORD_OAUTH2_PERMISSIONS found!");
interface Guild {
id: string;
name: string;
icon: string | null | undefined;
}
const initialValue = () => ({
success: null as boolean | null,
guilds: [] as {
id: string;
name: string;
icon: string | null | undefined;
}[],
joined: [] as Guild[],
canJoin: [] as Guild[],
});
const getPayload = async (): Promise<
@ -43,9 +48,10 @@ const getPayload = async (): Promise<
const { GET } = createClient<paths>({
baseUrl: "https://discord.com/api/v10",
});
const { data: guilds, error } = await GET("/users/@me/guilds", {
const { data, error } = await GET("/users/@me/guilds", {
headers: { Authorization: `Bearer ${tokens.accessToken}` },
});
const configs = await db.select().from(guilds).execute();
if (error) {
console.log("Discord api error:", { error });
@ -53,12 +59,21 @@ const getPayload = async (): Promise<
}
console.log(pathname, "success");
const joined: Guild[] = [];
const canJoin: Guild[] = [];
data
?.filter((e) => parseInt(e.permissions) & (1 << 5))
.forEach(({ id, name, icon }) => {
configs.map((e) => e.id.toString()).includes(id)
? joined.push({ id, name, icon })
: canJoin.push({ id, name, icon });
});
return {
success: true,
guilds:
guilds
?.filter((e) => parseInt(e.permissions) & (1 << 5))
.map(({ id, name, icon }) => ({ id, name, icon })) ?? [],
joined,
canJoin,
};
};
@ -84,64 +99,62 @@ function index() {
return payload;
},
{ deferStream: true },
{ deferStream: true, initialValue: initialValue() },
);
return (
<Layout site="config">
<div class="group">
<h3>Configure li&apos;l Judd in</h3>
<div>
<For each={payload()?.guilds}>
{(guild) => {
return (
<a href={`/config/${guild.id}`} class="flex-row centered">
<img
class="guildpfp"
src={
guild.icon
? `https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.webp?size=240`
: "https://cdn.discordapp.com/icons/1040502664506646548/bb5a51c4659cf47bdd942bb11e974da7.webp?size=240"
}
alt="Server pfp"
/>
<h1>{guild.name}</h1>
<FontAwesomeIcon icon={faWrench} size="xl" />
</a>
);
}}
</For>
</div>
<GuildList list={payload().joined} joined={true} />
</div>
<div class="group">
<h3>Add li&apos;l Judd to</h3>
<div>
<For each={payload()?.guilds}>
{(guild) => {
return (
<a
href={`https://discord.com/api/oauth2/authorize?client_id=${import.meta.env.VITE_DISCORD_CLIENT_ID}&permissions=${import.meta.env.VITE_DISCORD_OAUTH2_PERMISSIONS}&scope=bot&guild_id=${guild.id}`}
class="flex-row centered"
>
<img
class="guildpfp"
src={
guild.icon
? `https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.webp?size=240`
: "https://cdn.discordapp.com/icons/1040502664506646548/bb5a51c4659cf47bdd942bb11e974da7.webp?size=240"
}
alt="Server pfp"
/>
<h1>{guild.name}</h1>
<FontAwesomeIcon icon={faPlusCircle} size="xl" />
</a>
);
}}
</For>
</div>
<GuildList list={payload().canJoin} joined={false} />
</div>
</Layout>
);
}
function GuildList(props: { list: Guild[]; joined: boolean }) {
return (
<div>
<Show
when={props.list.length}
fallback={<div class="box flex-row centered">Nothing here</div>}
>
<For each={props.list}>
{(guild) => {
return (
<a
href={
props.joined
? `/config/${guild.id}`
: `https://discord.com/api/oauth2/authorize?client_id=${import.meta.env.VITE_DISCORD_CLIENT_ID}&permissions=${import.meta.env.VITE_DISCORD_OAUTH2_PERMISSIONS}&scope=bot&guild_id=${guild.id}`
}
class="box effect flex-row centered"
>
<img
class="guildpfp"
src={
guild.icon
? `https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.webp?size=240`
: "https://cdn.discordapp.com/icons/1040502664506646548/bb5a51c4659cf47bdd942bb11e974da7.webp?size=240"
}
alt="Server pfp"
/>
<h1>{guild.name}</h1>
<FontAwesomeIcon
icon={props.joined ? faWrench : faPlusCircle}
size="xl"
/>
</a>
);
}}
</For>
</Show>
</div>
);
}
export default index;

View file

@ -16,8 +16,7 @@
margin-right: 10px;
}
section,
a {
.box {
transition: 0.3s;
transition-timing-function: ease-out;
background-color: rgba(0, 0, 0, 0.5);
@ -28,28 +27,30 @@
margin: 1rem auto;
width: 100%;
svg {
padding: 0 1rem;
&.effect {
svg {
padding: 0 1rem;
path {
transition: 0.1s;
transform: translateX(100%);
opacity: 0;
path {
transition: 0.1s;
transform: translateX(100%);
opacity: 0;
}
}
}
&:hover {
transition: 1s;
transition-delay: 0.1s;
background-color: rgba(0, 0, 0, 0.4);
border: 2px solid rgba(255, 255, 255, 0.5);
svg path {
transition: 0.5s;
&:hover {
transition: 1s;
transition-delay: 0.1s;
transition-timing-function: ease-out;
transform: translateX(0);
opacity: 1;
background-color: rgba(0, 0, 0, 0.4);
border: 2px solid rgba(255, 255, 255, 0.5);
svg path {
transition: 0.5s;
transition-delay: 0.1s;
transition-timing-function: ease-out;
transform: translateX(0);
opacity: 1;
}
}
}
}