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 }; 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 const DayKeys = ["0", "1", "2", "3", "4", "5", "6"] as const;
export type DayKeys = (typeof DayKeys)[number]; export type DayKeys = (typeof DayKeys)[number];
export type Messages = Record<DayKeys, string | null>; export type Messages = Record<DayKeys, string | null>;

View file

@ -1,11 +1,16 @@
import { faToggleOff, faToggleOn } from "@fortawesome/pro-regular-svg-icons"; import { faToggleOff, faToggleOn } from "@fortawesome/pro-regular-svg-icons";
import { useLocation, useNavigate, useParams } from "@solidjs/router"; 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 moment from "moment-timezone";
import createClient from "openapi-fetch"; import createClient from "openapi-fetch";
import { import {
For, For,
Index, Index,
Show,
batch,
createEffect, createEffect,
createMemo,
createResource, createResource,
createSignal, createSignal,
} from "solid-js"; } from "solid-js";
@ -13,7 +18,11 @@ import { createStore } from "solid-js/store";
import { getRequestEvent } from "solid-js/web"; import { getRequestEvent } from "solid-js/web";
import { FontAwesomeIcon } from "~/components/FontAwesomeIcon"; import { FontAwesomeIcon } from "~/components/FontAwesomeIcon";
import Layout from "~/components/Layout"; import Layout from "~/components/Layout";
import db from "~/drizzle";
import { guilds } from "~/drizzle/schema";
import getAccessToken from "~/lib/accessToken"; import getAccessToken from "~/lib/accessToken";
import { combineInterval, splitInterval } from "~/lib/responseBuilders";
import { zodBigIntId } from "~/lib/zod";
import { paths } from "~/types/discord"; import { paths } from "~/types/discord";
import "../../styles/pages/config.scss"; 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 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 ( const getPayload = async (
id: string, id: string,
): Promise< ): Promise<
| { success: false; message: string } | { 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"; "use server";
let guildId: bigint;
try {
guildId = zodBigIntId.parse(id);
} catch (e) {
return { success: false, message: "ID is invalid" };
}
const event = getRequestEvent(); const event = getRequestEvent();
if (!event) return { success: false, message: "No request event!" }; if (!event) return { success: false, message: "No request event!" };
@ -63,7 +83,7 @@ const getPayload = async (
{ {
params: { params: {
path: { path: {
guild_id: id, guild_id: String(guildId),
}, },
}, },
headers: { 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) if (!guild)
return { return {
@ -95,7 +115,10 @@ const getPayload = async (
"User is no MANAGE_GUILD permissions on this guild with requested id!", "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) => { channelsData?.forEach((channel) => {
if (channel.type !== 0) return; if (channel.type !== 0) return;
channels.push({ 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"); console.log(new URL(event.request.url).pathname, "success");
return { return {
@ -112,47 +141,80 @@ const getPayload = async (
id: guild.id, id: guild.id,
name: guild.name, name: guild.name,
icon: guild.icon, icon: guild.icon,
channel: "", tpChannelId: String(config.tpChannelId ?? ""),
channels, channels,
tpInterval: config.tpInterval,
pingableRoles: config.tpRolesEnabled,
timezone: config.timezone,
}, },
tzNames: moment.tz.names(), 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() { function config() {
const params = useParams(); const params = useParams();
const navigator = useNavigate(); const navigator = useNavigate();
const location = useLocation(); const location = useLocation();
const [timezoneRef, setTimezoneRef] = createSignal<HTMLInputElement>(); const [timezoneRef, setTimezoneRef] = createSignal<HTMLInputElement>();
const [timePlanningRef, setTimePlanningRef] = const [tpEnabledRef, setTpEnabledRef] = createSignal<HTMLInputElement>();
createSignal<HTMLInputElement>();
const [channelRef, setChannelRef] = createSignal<HTMLSelectElement>(); const [channelRef, setChannelRef] = createSignal<HTMLSelectElement>();
const [targetMinuteRef, setTargetMinuteRef] =
createSignal<HTMLSelectElement>();
const [targetHourRef, setTargetHourRef] = createSignal<HTMLSelectElement>();
const [targetDayRef, setTargetDayRef] = createSignal<HTMLSelectElement>();
const [pingableRolesRef, setPingableRolesRef] = const [pingableRolesRef, setPingableRolesRef] =
createSignal<HTMLInputElement>(); createSignal<HTMLInputElement>();
const [timezone, setTimezone] = createSignal(guessTZ()); const [payload, { refetch }] = createResource(
const [payload] = createResource(
params.guildId, params.guildId,
async (id) => { async (id) => {
const payload = await getPayload(id).catch((e) => console.warn(e, id)); const payload = await getPayload(id);
if (!payload) { if (!payload) {
console.error(location.pathname, payload); console.error(location.pathname, payload);
return initialValue(params); return undefined;
} }
if (!payload.success) { if (!payload.success) {
console.log(payload); console.log(payload);
console.log(location.pathname, payload.message, "No success"); console.log(location.pathname, payload.message, "No success");
navigator("/config", { replace: false }); navigator("/config", { replace: false });
return initialValue(params); return undefined;
} }
return payload; return payload;
}, },
{ {
initialValue: initialValue(params),
deferStream: true, deferStream: true,
}, },
); );
@ -160,37 +222,103 @@ function config() {
features: { features: {
timePlanning: { timePlanning: {
enabled: false, enabled: false,
channelId: "833442323160891452", channelId: "",
targetMinute: 0,
targetHour: 0,
targetDay: 0,
pingableRoles: false, 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(() => { createEffect(() => {
const channelId = payload().guild.channel; 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", "channelId", channelId);
const ref = channelRef(); setConfig("features", "timePlanning", "targetMinute", targetMinute);
if (!ref) return; setConfig("features", "timePlanning", "targetHour", targetHour);
ref.value = channelId; setConfig("features", "timePlanning", "targetDay", targetDay);
setConfig("features", "timePlanning", "pingableRoles", pingableRoles);
setConfig("features", "timePlanning", "timezone", timezone);
});
}); });
createEffect(() => { createEffect(() => {
const ref = timezoneRef(); const ref = timezoneRef();
if (!ref) return; if (ref) ref.value = config.features.timePlanning.timezone;
ref.value = timezone();
}); });
createEffect(() => { createEffect(() => {
const ref = timePlanningRef(); const ref = tpEnabledRef();
if (!ref) return; if (ref) ref.checked = config.features.timePlanning.enabled;
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(() => { createEffect(() => {
const ref = pingableRolesRef(); const ref = pingableRolesRef();
if (!ref) return; if (ref) ref.checked = config.features.timePlanning.pingableRoles;
ref.checked = config.features.timePlanning.pingableRoles;
}); });
return ( return (
<Layout site="config"> <Layout site="config">
<div class="group">
<h3>Configure li&apos;l Judd in</h3> <h3>Configure li&apos;l Judd in</h3>
<div> <div>
<div> <div>
@ -198,19 +326,19 @@ function config() {
<img <img
class="guildpfp" class="guildpfp"
src={ src={
payload().guild.icon payload()?.guild.icon
? `https://cdn.discordapp.com/icons/${payload().guild.id}/${ ? `https://cdn.discordapp.com/icons/${payload()?.guild.id}/${
payload().guild.icon payload()?.guild.icon
}.webp?size=240` }.webp?size=240`
: "https://cdn.discordapp.com/icons/1040502664506646548/bb5a51c4659cf47bdd942bb11e974da7.webp?size=240" : "https://cdn.discordapp.com/icons/1040502664506646548/bb5a51c4659cf47bdd942bb11e974da7.webp?size=240"
} }
alt="Server pfp" alt="Server pfp"
/> />
<h1>{payload().guild.name}</h1> <h1>{payload()?.guild.name}</h1>
</div> </div>
</div> </div>
<section> <section class="box">
<h2>Guild</h2> <h2>Guild</h2>
<p>General settings for this guild.</p> <p>General settings for this guild.</p>
<div class="flex-row"> <div class="flex-row">
@ -220,34 +348,44 @@ function config() {
list="timezones" list="timezones"
id="timezone" id="timezone"
ref={(e) => setTimezoneRef(e)} ref={(e) => setTimezoneRef(e)}
// disabled={!tzNames().find((e) => e === timezone())} onInput={(e) =>
onInput={(e) => setTimezone(e.target.value)} setConfig(
"features",
"timePlanning",
"timezone",
e.target.value,
)
}
/> />
<datalist id="timezones"> <datalist id="timezones">
<Index each={payload().tzNames}> <Index each={payload()?.tzNames}>
{(zone) => <option value={zone()} />} {(zone) => <option value={zone()} />}
</Index> </Index>
</datalist> </datalist>
<button <button
disabled={guessTZ() === timezone()} disabled={guessTZ() === config.features.timePlanning.timezone}
title={"Detected: " + guessTZ()} title={"Detected: " + guessTZ()}
onClick={() => setTimezone(guessTZ())} onClick={() =>
setConfig("features", "timePlanning", "timezone", guessTZ())
}
> >
Auto-detect Auto-detect
</button> </button>
</div> </div>
</section> </section>
<section> <section class="box">
<h2>Features</h2> <h2>Features</h2>
<p>Configure the features of the bot</p> <p>Configure the features of the bot</p>
<label for="timePlanning" class="flex-row"> <label for="timePlanning" class="flex-row">
<p>Time Planning </p> <p>Time Planning </p>
<FontAwesomeIcon <FontAwesomeIcon
icon={ icon={
config.features.timePlanning.enabled ? faToggleOn : faToggleOff config.features.timePlanning.enabled
? faToggleOn
: faToggleOff
} }
size="xl" size="xl"
/> />
@ -256,9 +394,14 @@ function config() {
hidden hidden
type="checkbox" type="checkbox"
id="timePlanning" id="timePlanning"
ref={(e) => setTimePlanningRef(e)} ref={(e) => setTpEnabledRef(e)}
onInput={(e) => onInput={(e) =>
setConfig("features", "timePlanning", "enabled", e.target.checked) setConfig(
"features",
"timePlanning",
"enabled",
e.target.checked,
)
} }
/> />
<div <div
@ -281,12 +424,89 @@ function config() {
<option disabled value=""> <option disabled value="">
--Select a Channel-- --Select a Channel--
</option> </option>
<For each={payload().guild.channels}> <For each={payload()?.guild.channels}>
{(channel) => ( {(channel) => (
<option value={channel.id}>{channel.name}</option> <option value={channel.id}>{channel.name}</option>
)} )}
</For> </For>
</select> </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>
<div class="flex-row"> <div class="flex-row">
<label for="pingableRoles" class="flex-row"> <label for="pingableRoles" class="flex-row">
@ -318,11 +538,22 @@ function config() {
</div> </div>
</section> </section>
<section> <section class="box">
<button>Apply</button> <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> <button onClick={() => navigator("/config")}>Back</button>
<Show when={willUpdateValues()}>UNSAVED CHANGES</Show>
</section> </section>
</div> </div>
</div>
</Layout> </Layout>
); );
} }

View file

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

View file

@ -16,8 +16,7 @@
margin-right: 10px; margin-right: 10px;
} }
section, .box {
a {
transition: 0.3s; transition: 0.3s;
transition-timing-function: ease-out; transition-timing-function: ease-out;
background-color: rgba(0, 0, 0, 0.5); background-color: rgba(0, 0, 0, 0.5);
@ -28,6 +27,7 @@
margin: 1rem auto; margin: 1rem auto;
width: 100%; width: 100%;
&.effect {
svg { svg {
padding: 0 1rem; padding: 0 1rem;
@ -53,6 +53,7 @@
} }
} }
} }
}
.sub { .sub {
margin-left: 10px; margin-left: 10px;