initial commit: fork

This commit is contained in:
2023-10-27 10:31:20 +02:00
commit a52dbbc103
195 changed files with 17484 additions and 0 deletions

28
apps/dashboard/README.md Normal file
View File

@@ -0,0 +1,28 @@
# Create T3 App
This is a [T3 Stack](https://create.t3.gg/) project bootstrapped with `create-t3-app`.
## What's next? How do I make an app with this?
We try to keep this project as simple as possible, so you can start with just the scaffolding we set up for you, and add additional things later when they become necessary.
If you are not familiar with the different technologies used in this project, please refer to the respective docs. If you still are in the wind, please join our [Discord](https://t3.gg/discord) and ask for help.
- [Next.js](https://nextjs.org)
- [NextAuth.js](https://next-auth.js.org)
- [Prisma](https://prisma.io)
- [Tailwind CSS](https://tailwindcss.com)
- [tRPC](https://trpc.io)
## Learn More
To learn more about the [T3 Stack](https://create.t3.gg/), take a look at the following resources:
- [Documentation](https://create.t3.gg/)
- [Learn the T3 Stack](https://create.t3.gg/en/faq#what-learning-resources-are-currently-available) — Check out these awesome tutorials
You can check out the [create-t3-app GitHub repository](https://github.com/t3-oss/create-t3-app) — your feedback and contributions are welcome!
## How do I deploy this?
Follow our deployment guides for [Vercel](https://create.t3.gg/en/deployment/vercel) and [Docker](https://create.t3.gg/en/deployment/docker) for more information.

View File

@@ -0,0 +1,16 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/app/styles/globals.css",
"baseColor": "slate",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"
}
}

5
apps/dashboard/next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.

View File

@@ -0,0 +1,21 @@
// Importing env files here to validate on build
import './src/env.mjs';
import '@master-bot/auth/env.mjs';
/** @type {import("next").NextConfig} */
const config = {
reactStrictMode: true,
/** Enables hot reloading for local packages without a build step */
transpilePackages: ['@master-bot/api', '@master-bot/auth', '@master-bot/db'],
/** We already do linting and typechecking as separate tasks in CI */
eslint: { ignoreDuringBuilds: true },
typescript: { ignoreBuildErrors: true },
images: {
domains: ['cdn.discordapp.com']
},
experimental: {
serverActions: true
}
};
export default config;

View File

@@ -0,0 +1,66 @@
{
"name": "@master-bot/dashboard",
"version": "0.1.0",
"private": true,
"scripts": {
"build": "pnpm with-env next build",
"clean": "git clean -xdf .next .turbo node_modules",
"dev": "pnpm with-env next dev",
"lint": "dotenv -v SKIP_ENV_VALIDATION=1 next lint",
"lint:fix": "pnpm lint --fix",
"start": "pnpm with-env next start",
"type-check": "tsc --noEmit",
"with-env": "dotenv -e ../../.env --"
},
"dependencies": {
"@master-bot/api": "^0.1.0",
"@master-bot/auth": "^0.1.0",
"@master-bot/db": "^0.1.0",
"@radix-ui/react-dropdown-menu": "^2.0.5",
"@radix-ui/react-select": "^1.2.2",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-toast": "^1.1.4",
"@t3-oss/env-nextjs": "^0.6.0",
"@tanstack/react-query": "^4.32.1",
"@tanstack/react-query-devtools": "^4.32.1",
"@tanstack/react-query-next-experimental": "5.0.0-alpha.80",
"@trpc/client": "^10.37.1",
"@trpc/next": "^10.37.1",
"@trpc/react-query": "^10.37.1",
"@trpc/server": "^10.37.1",
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
"discord-api-types": "^0.37.51",
"lucide-react": "^0.263.1",
"next": "^13.4.12",
"next-themes": "^0.2.1",
"react": "18.2.0",
"react-dom": "18.2.0",
"superjson": "1.13.1",
"tailwind-merge": "^1.14.0",
"tailwindcss-animate": "^1.0.6",
"zod": "^3.21.4"
},
"devDependencies": {
"@master-bot/eslint-config": "^0.2.0",
"@master-bot/tailwind-config": "^0.1.0",
"@types/node": "^20.4.6",
"@types/react": "^18.2.18",
"@types/react-dom": "^18.2.7",
"autoprefixer": "^10.4.14",
"dotenv-cli": "^7.2.1",
"eslint": "^8.46.0",
"postcss": "^8.4.27",
"tailwindcss": "^3.3.3",
"typescript": "^5.1.6"
},
"eslintConfig": {
"root": true,
"extends": [
"@master-bot/eslint-config/base",
"@master-bot/eslint-config/nextjs",
"@master-bot/eslint-config/react"
]
}
}

View File

@@ -0,0 +1,2 @@
// @ts-expect-error - No types for postcss
module.exports = require('@master-bot/tailwind-config/postcss');

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

View File

@@ -0,0 +1,13 @@
<svg width="258" height="198" viewBox="0 0 258 198" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1_12)">
<path d="M165.269 24.0976L188.481 -0.000411987H0V24.0976H165.269Z" fill="black"/>
<path d="M163.515 95.3516L253.556 2.71059H220.74L145.151 79.7886L163.515 95.3516Z" fill="black"/>
<path d="M233.192 130.446C233.192 154.103 214.014 173.282 190.357 173.282C171.249 173.282 155.047 160.766 149.534 143.467L146.159 132.876L126.863 152.171L128.626 156.364C138.749 180.449 162.568 197.382 190.357 197.382C227.325 197.382 257.293 167.414 257.293 130.446C257.293 105.965 243.933 84.7676 224.49 73.1186L219.929 70.3856L202.261 88.2806L210.322 92.5356C223.937 99.7236 233.192 114.009 233.192 130.446Z" fill="black"/>
<path d="M87.797 191.697V44.6736H63.699V191.697H87.797Z" fill="black"/>
</g>
<defs>
<clipPath id="clip0_1_12">
<rect width="258" height="198" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 923 B

View File

@@ -0,0 +1,6 @@
export { GET, POST } from '@master-bot/auth';
// @note If you wanna enable edge runtime, either
// - https://auth-docs-git-feat-nextjs-auth-authjs.vercel.app/guides/upgrade-to-v5#edge-compatibility
// - swap prisma for kysely / drizzle
// export const runtime = "edge";

View File

@@ -0,0 +1,38 @@
import { appRouter, createTRPCContext } from '@master-bot/api';
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { auth } from '@master-bot/auth';
export const runtime = 'nodejs';
/**
* Configure basic CORS headers
* You should extend this to match your needs
*/
function setCorsHeaders(res: Response) {
res.headers.set('Access-Control-Allow-Origin', '*');
res.headers.set('Access-Control-Request-Method', '*');
res.headers.set('Access-Control-Allow-Methods', 'OPTIONS, GET, POST');
res.headers.set('Access-Control-Allow-Headers', '*');
}
export function OPTIONS() {
const response = new Response(null, {
status: 204
});
setCorsHeaders(response);
return response;
}
const handler = auth(async req => {
const response = await fetchRequestHandler({
endpoint: '/api/trpc',
router: appRouter,
req,
createContext: () => createTRPCContext({ auth: req.auth, req })
});
setCorsHeaders(response);
return response;
});
export { handler as GET, handler as POST };

View File

@@ -0,0 +1,412 @@
'use client';
import {
type APIRole,
type APIApplicationCommandPermission,
ApplicationCommandPermissionType
} from 'discord-api-types/v10';
import { useState } from 'react';
import { api } from '~/utils/api';
import { useToast } from '~/components/ui/use-toast';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger
} from '~/components/ui/dropdown';
interface Role {
name: string;
id: string;
color: number;
}
export default function CommandPage({
params
}: {
params: {
server_id: string;
command_id: string;
};
}) {
const { data, isLoading } = api.command.getCommandAndGuildChannels.useQuery(
{
guildId: params.server_id,
commandId: params.command_id
},
{
refetchOnReconnect: false,
retryOnMount: false,
refetchOnWindowFocus: false
}
);
if (isLoading) return <div>Loading...</div>;
if (!data?.command) return <div>Command not found</div>;
return (
<>
<h1 className="text-3xl font-semibold mb-4">Edit {data.command.name}</h1>
<PermissionsEdit
roles={sortRolePermissions({
roles: data.roles,
permissions: data.permissions
})}
allRoles={data.roles}
guildId={params.server_id}
commandId={params.command_id}
/>
</>
);
}
const PermissionsEdit = ({
roles,
allRoles,
guildId,
commandId
}: {
roles: {
allowedRoles: Role[];
deniedRoles: Role[];
};
allRoles: APIRole[];
guildId: string;
commandId: string;
}) => {
const { toast } = useToast();
const allowedIds = roles.allowedRoles.map(r => r.id);
const deniedIds = roles.deniedRoles.map(r => r.id);
const [allowedRoles, setAllowedRoles] = useState(roles.allowedRoles);
const [deniedRoles, setDeniedRoles] = useState(roles.deniedRoles);
const [disableSave, setDisableSave] = useState(false);
const [selectedRadio, setSelectedRadio] = useState(
allowedIds.length ? 'deny' : 'allow'
);
const isRadioSelected = (value: string) => selectedRadio === value;
const handleRadioClick = (e: React.ChangeEvent<HTMLInputElement>): void =>
setSelectedRadio(e.currentTarget.value);
const { mutate } = api.command.editCommandPermissions.useMutation();
const utils = api.useContext();
function handleRoleChange({ id, type }: { id: string; type: string }) {
if (type === 'allow') {
const newAllowedRoles = allowedRoles.filter(role => role.id !== id);
setAllowedRoles(newAllowedRoles);
} else if (type === 'deny') {
const newDeniedRoles = deniedRoles.filter(role => role.id !== id);
setDeniedRoles(newDeniedRoles);
}
}
function handleSave() {
setDisableSave(true);
const allowedPerms = allowedRoles.map(role => ({
id: role.id,
type: 1,
permission: true
}));
const deniedPerms = deniedRoles.map(role => ({
id: role.id,
type: 1,
permission: false
}));
mutate(
{
guildId,
commandId,
permissions: selectedRadio === 'allow' ? deniedPerms : allowedPerms,
type: selectedRadio
},
{
onSuccess: async () => {
await utils.command.getCommandAndGuildChannels.invalidate();
setDisableSave(false);
toast({
title: 'Permissions updated'
});
},
onError: () => {
setDisableSave(false);
toast({
title: 'An error occurred while updating permissions.'
});
},
onSettled: () => {
setDisableSave(false);
}
}
);
}
return (
<div className="bg-gray-900 p-5 rounded-lg">
<div className="flex justify-between">
<h1 className="text-slate-300 font-bold text-xl">Permissions</h1>
<button
disabled={disableSave}
onClick={handleSave}
className="bg-green-600 text-white rounded-lg px-3 py-1 hover:bg-green-500"
>
Save
</button>
</div>
<div className="mt-10 flex flex-col gap-4">
<h2 className="font-bold text-slate-300">Role permissions</h2>
<div className="w-fit">
<div className="flex gap-2">
<input
type="radio"
checked={isRadioSelected('allow')}
value="allow"
name="role"
onChange={handleRadioClick}
/>
<h1>Allow for everyone except</h1>
</div>
{selectedRadio === 'deny' ? null : (
<div className="max-w-[320px] flex gap-4 flex-wrap bg-black rounded-lg">
{deniedRoles.map(role => {
if (role.name === '@everyone') return null;
return (
<div
key={role.id}
style={{
backgroundColor:
role.color.toString(16) == '0'
? 'gray'
: `#${role.color.toString(16)}`
}}
className={`flex rounded-lg px-2 py-1 text-white items-center`}
>
<div>
{role.name == '@everyone' ? '@everyone' : `@${role.name}`}
</div>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="cursor-pointer ml-1"
onClick={() =>
handleRoleChange({ id: role.id, type: 'deny' })
}
>
<path
d="M7.757 7.757l8.486 8.486m0-8.486l-8.486 8.486"
stroke="#9B9D9F"
strokeWidth="1.5"
strokeLinecap="round"
></path>
</svg>
</div>
);
})}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className={`p-2 text-white hover:cursor-pointer`}
>
+
</button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56 h-96 overflow-auto">
<DropdownMenuGroup>
{allRoles
.filter(role => !deniedIds.includes(role.id))
.map(role => {
if (role.name === '@everyone') return;
return (
<DropdownMenuItem
className="h-6 dark:text-white"
key={role.id}
onClick={() => {
setDeniedRoles(state => [
...state,
{
id: role.id,
name: role.name,
color: role.color
}
]);
if (allowedIds.includes(role.id)) {
setAllowedRoles(state =>
state.filter(r => r.id !== role.id)
);
}
}}
>
{role.name}
</DropdownMenuItem>
);
})}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
</div>
<div className="w-fit">
<div className="flex gap-2">
<input
type="radio"
checked={isRadioSelected('deny')}
value="deny"
name="role"
onChange={handleRadioClick}
/>
<h1>Deny for everyone except</h1>
</div>
{selectedRadio === 'deny' ? (
<div className="max-w-[320px] flex gap-4 flex-wrap bg-black rounded-lg">
{allowedRoles.map(role => {
if (role.name == '@everyone') return null;
return (
<div
key={role.id}
style={{
backgroundColor:
role.color.toString(16) == '0'
? 'gray'
: `#${role.color.toString(16)}`
}}
className={`flex rounded-lg px-2 py-1 text-white items-center`}
>
{role.name == '@everyone' ? '@everyone' : `@${role.name}`}
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="cursor-pointer ml-1"
onClick={() =>
handleRoleChange({ id: role.id, type: 'allow' })
}
>
<path
d="M7.757 7.757l8.486 8.486m0-8.486l-8.486 8.486"
stroke="#9B9D9F"
strokeWidth="1.5"
strokeLinecap="round"
></path>
</svg>
</div>
);
})}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className={`p-2 text-white hover:cursor-pointer`}
>
+
</button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56 h-96 overflow-auto">
<DropdownMenuGroup>
{allRoles
.filter(role => !allowedIds.includes(role.id))
.map(role => {
if (role.name === '@everyone') return;
return (
<DropdownMenuItem
className="h-6 dark:text-white"
key={role.id}
onClick={() => {
setAllowedRoles(state => [
...state,
{
id: role.id,
name: role.name,
color: role.color
}
]);
if (deniedIds.includes(role.id)) {
setDeniedRoles(state =>
state.filter(r => r.id !== role.id)
);
}
}}
>
{role.name}
</DropdownMenuItem>
);
})}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
) : null}
</div>
</div>
</div>
);
};
function sortRolePermissions({
roles,
permissions
}: {
roles: APIRole[];
permissions: any;
}) {
if (permissions.code) {
return {
allowedRoles: [],
deniedRoles: []
};
}
const allowedRoles: Role[] = permissions.permissions
.filter(
(permission: APIApplicationCommandPermission) =>
permission.type === ApplicationCommandPermissionType.Role &&
permission.permission
)
.map((permission: APIApplicationCommandPermission) => {
const role = roles.find(roles => roles.id === permission.id);
return {
name: role?.name,
id: role?.id,
color: role?.color
};
});
const deniedRoles: Role[] = permissions.permissions
.filter(
(permission: APIApplicationCommandPermission) =>
permission.type === ApplicationCommandPermissionType.Role &&
!permission.permission
)
.map((permission: APIApplicationCommandPermission) => {
const role = roles.find(roles => roles.id === permission.id);
return {
name: role?.name,
id: role?.id,
color: role?.color
};
});
return {
allowedRoles,
deniedRoles
};
}

View File

@@ -0,0 +1,48 @@
'use server';
import { prisma } from '@master-bot/db';
import { revalidatePath } from 'next/cache';
export async function toggleCommand(
guildId: string,
commandId: string,
newStatus: boolean
) {
const guild = await prisma.guild.findUnique({
where: {
id: guildId
},
select: {
disabledCommands: true
}
});
if (!guild) {
throw new Error('Guild not found');
}
if (newStatus) {
await prisma.guild.update({
where: {
id: guildId
},
data: {
disabledCommands: {
set: guild.disabledCommands.filter(id => id !== commandId)
}
}
});
} else {
await prisma.guild.update({
where: {
id: guildId
},
data: {
disabledCommands: {
push: commandId
}
}
});
}
revalidatePath(`/dashboard/${guildId}/commands`);
}

View File

@@ -0,0 +1,4 @@
export default function Loading() {
// You can add any UI inside Loading, including a Skeleton.
return <div>Loading...</div>;
}

View File

@@ -0,0 +1,78 @@
import { env } from '~/env.mjs';
import { prisma } from '@master-bot/db';
import type { APIApplicationCommand } from 'discord-api-types/v10';
import CommandToggleSwitch from './toggle-command';
import Link from 'next/link';
async function getApplicationCommands() {
// get all commands
const response = await fetch(
`https://discordapp.com/api/applications/${env.DISCORD_CLIENT_ID}/commands`,
{
headers: {
Authorization: `Bot ${env.DISCORD_TOKEN}`
}
}
);
return (await response.json()) as APIApplicationCommand[];
}
export default async function CommandsPage({
params
}: {
params: { server_id: string };
}) {
// get disabled commands
const guild = await prisma.guild.findUnique({
where: { id: params.server_id },
select: { disabledCommands: true }
});
const commands = await getApplicationCommands();
return (
<div>
<h1 className="text-3xl font-semibold mb-4">
Enable / Disable Commands Panel
</h1>
{commands ? (
<div className="flex flex-col gap-4">
{commands.map(command => {
const isCommandEnabled = !guild?.disabledCommands.includes(
command.id
);
return (
<div
key={command.id}
className={`${
isCommandEnabled
? 'dark:bg-slate-700 bg-slate-400'
: 'dark:bg-slate-800 bg-slate-500'
} border-b flex justify-between items-center dark:border-slate-400 border-slate-700 px-2 py-1`}
>
<div className="flex flex-col gap-1">
<Link
href={`/dashboard/${params.server_id}/commands/${command.id}`}
>
<h3 className="text-lg">{command.name}</h3>
</Link>
<p className="text-sm">{command.description}</p>
</div>
<div>
<CommandToggleSwitch
commandEnabled={isCommandEnabled}
serverId={params.server_id}
commandId={command.id}
/>
</div>
</div>
);
})}
</div>
) : (
<div className="text-red-500">Error loading commands</div>
)}
</div>
);
}

View File

@@ -0,0 +1,36 @@
'use client';
import { Switch } from '~/components/ui/switch';
import { startTransition } from 'react';
import { toggleCommand } from './actions';
import { useToast } from '~/components/ui/use-toast';
import { ToastAction } from '~/components/ui/toast';
export default function CommandToggleSwitch({
commandEnabled,
serverId,
commandId
}: {
commandEnabled: boolean;
serverId: string;
commandId: string;
}) {
const { toast } = useToast();
return (
<Switch
checked={commandEnabled}
onCheckedChange={() =>
startTransition(() =>
// @ts-ignore
toggleCommand(serverId, commandId, !commandEnabled).then(() => {
toast({
title: `Command ${commandEnabled ? 'disabled' : 'enabled'}`,
action: <ToastAction altText="Okay">Okay</ToastAction>
});
})
)
}
/>
);
}

View File

@@ -0,0 +1,46 @@
import { auth } from '@master-bot/auth';
import { redirect } from 'next/navigation';
import { prisma } from '@master-bot/db';
import Sidebar from './sidebar';
import HeaderButtons from '~/components/header-buttons';
export default async function Layout({
params,
children
}: {
params: { server_id: string };
children: React.ReactNode;
}) {
const session = await auth();
if (!session?.user) {
redirect('/');
}
const guild = await prisma.guild.findUnique({
where: {
id: params.server_id,
ownerId: session.user.discordId
}
});
if (!guild) {
redirect('/');
}
return (
<div className="flex h-screen">
<section className="border-r border-slate-600 px-6 py-4">
<Sidebar server_id={params.server_id} />
</section>
<section className="flex-1 flex flex-col">
<header className="flex justify-end px-6 py-4">
<HeaderButtons />
</header>
<main className="dark:bg-slate-800 bg-slate-300 flex-1 p-6 overflow-auto">
{children}
</main>
</section>
</div>
);
}

View File

@@ -0,0 +1,7 @@
export default function ServerIndexPage() {
return (
<div>
<h2>Guild index page</h2>
</div>
);
}

View File

@@ -0,0 +1,38 @@
import Link from 'next/link';
import { MessageCircle, ChevronRightSquare } from 'lucide-react';
import Logo from '~/components/logo';
const links = [
{
href: 'commands',
label: 'Commands',
icon: ChevronRightSquare
},
{
href: 'welcome-message',
label: 'Welcome Message',
icon: MessageCircle
}
];
export default function Sidebar({ server_id }: { server_id: string }) {
return (
<aside className="flex flex-col items-center gap-10">
<Link href={`/dashboard/${server_id}`}>
<Logo size="medium" />
</Link>
<div className="flex flex-col gap-6">
{links.map(link => (
<Link
key={link.href}
className="flex gap-4"
href={`/dashboard/${server_id}/${link.href}`}
>
<link.icon size={24} />
<p className="text-xl">{link.label}</p>
</Link>
))}
</div>
</aside>
);
}

View File

@@ -0,0 +1,32 @@
'use server';
import { prisma } from '@master-bot/db';
import { revalidatePath } from 'next/cache';
export async function toggleWelcomeMessage(status: boolean, server_id: string) {
await prisma.guild.update({
where: {
id: server_id
},
data: {
welcomeMessageEnabled: status
}
});
revalidatePath(`/dashboard/${server_id}/welcome-message`);
}
export async function setWelcomeMessage(data: FormData) {
const guildId = data.get('guildId') as string;
const message = data.get('message') as string;
await prisma.guild.update({
where: {
id: guildId
},
data: {
welcomeMessage: message
}
});
revalidatePath(`/dashboard/${guildId}/welcome-message`);
}

View File

@@ -0,0 +1,4 @@
export default function Loading() {
// You can add any UI inside Loading, including a Skeleton.
return <div>Loading...</div>;
}

View File

@@ -0,0 +1,60 @@
import { prisma } from '@master-bot/db';
import WelcomeMessageToggle from './switch';
import { setWelcomeMessage } from './actions';
import { Button } from '~/components/ui/button';
import WelcomeMessageChannelSet from './set-channel';
function getGuildById(id: string) {
return prisma.guild.findUnique({
where: {
id
}
});
}
export default async function WelcomeMessagePage({
params
}: {
params: { server_id: string };
}) {
const guild = await getGuildById(params.server_id);
if (!guild) {
return <div>Error loading guild</div>;
}
return (
<>
<h1 className="text-3xl font-semibold">Welcome Message Settings</h1>
<div className="ml-2 mt-6 flex flex-col gap-6">
<h3>Welcome new users with a custom message</h3>
<div className="flex items-center gap-5">
{guild.welcomeMessageEnabled ? (
<p className="text-green-500">Enabled</p>
) : (
<p className="text-red-500">Disabled</p>
)}
<WelcomeMessageToggle
welcomeMessageEnabled={guild.welcomeMessageEnabled}
serverId={params.server_id}
/>
</div>
{guild.welcomeMessageEnabled && (
<div className="flex flex-col gap-4">
<form action={setWelcomeMessage}>
<input type="hidden" name="guildId" value={params.server_id} />
<textarea
name="message"
placeholder="welcome message"
defaultValue={guild.welcomeMessage ?? ''}
className="block mb-2 -ml-1 w-full bg-black outline-none overflow-auto my-2 resize-none p-4 text-white rounded-lg border border-gray-800 focus:ring-blue-600 focus:border-blue-600"
/>
<Button type="submit">Submit</Button>
</form>
<WelcomeMessageChannelSet guildId={params.server_id} />
</div>
)}
</div>
</>
);
}

View File

@@ -0,0 +1,95 @@
'use client';
import { api } from '~/utils/api';
import { useState } from 'react';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '~/components/ui/select';
import { Button } from '~/components/ui/button';
import { useToast } from '~/components/ui/use-toast';
export default function WelcomeMessageChannelSet({
guildId
}: {
guildId: string;
}) {
const { toast } = useToast();
const { data: channelData, isLoading: isLoadingChannelData } =
api.welcome.getChannel.useQuery(
{
guildId
},
{
refetchOnReconnect: false,
retryOnMount: false,
refetchOnWindowFocus: false
}
);
const [value, setValue] = useState(channelData?.guild?.welcomeMessageChannel);
const { data, isLoading } = api.channel.getAll.useQuery({
guildId
});
const { mutate } = api.welcome.setChannel.useMutation();
return (
<div className="flex flex-col gap-2">
<p className="text-xl">Welcome Message Channel</p>
{isLoading && !data && isLoadingChannelData && !channelData ? (
<div>Loading channels...</div>
) : (
<div>
<Select
onValueChange={setValue}
defaultValue={channelData?.guild?.welcomeMessageChannel ?? ''}
>
<SelectTrigger className="w-44">
<SelectValue placeholder="Select a channel" />
</SelectTrigger>
<SelectContent>
{data?.channels.map(channel => (
<SelectItem key={channel.id} value={channel.id}>
{channel.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
className="mt-2"
type="button"
onClick={() => {
if (!value) return;
mutate(
{
guildId,
channelId: value
},
{
onSuccess: () => {
toast({
title: 'Welcome message channel set'
});
},
onError: () => {
toast({
title: 'Error setting welcome message channel',
description: 'Please try again later'
});
}
}
);
}}
>
Set Channel
</Button>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,36 @@
'use client';
import { Switch } from '~/components/ui/switch';
import { startTransition } from 'react';
import { toggleWelcomeMessage } from './actions';
import { useToast } from '~/components/ui/use-toast';
import { ToastAction } from '~/components/ui/toast';
export default function ToggleSwitch({
welcomeMessageEnabled,
serverId
}: {
welcomeMessageEnabled: boolean;
serverId: string;
}) {
const { toast } = useToast();
return (
<Switch
checked={welcomeMessageEnabled}
onCheckedChange={() =>
startTransition(() =>
// @ts-ignore
toggleWelcomeMessage(!welcomeMessageEnabled, serverId).then(() => {
toast({
title: `Welcome message ${
welcomeMessageEnabled ? 'disabled' : 'enabled'
}`,
action: <ToastAction altText="Okay">Okay</ToastAction>
});
})
)
}
/>
);
}

View File

@@ -0,0 +1,57 @@
'use client';
import Link from 'next/link';
import { Button } from '~/components/ui/button';
import { api } from '~/utils/api';
import { env } from '~/env.mjs';
export default function GuildsList() {
const { data, isLoading, isError } = api.guild.getAll.useQuery(undefined, {
refetchOnReconnect: false,
retryOnMount: false,
refetchOnWindowFocus: false
});
if (isLoading) return <div className="text-white">Loading...</div>;
if (isError) return <div className="text-white">Error</div>;
return (
<>
{data ? (
<div className="flex gap-14">
{data.apiGuilds.map(guild => (
<div
className="text-white flex flex-col items-center"
key={guild.id}
>
<p className="font-semibold text-lg">{guild.name}</p>
{data.dbGuildsIds.includes(guild.id) ? (
<Button
className="bg-orange-500 hover:bg-orange-600 text-white"
asChild
>
<Link href={`/dashboard/${guild.id}`}>Manage</Link>
</Button>
) : (
<Button variant="link" asChild>
<a
href={env.NEXT_PUBLIC_INVITE_URL}
target="_blank"
rel="noreferrer"
>
Invite
</a>
</Button>
)}
</div>
))}
</div>
) : (
<div>
<p className="text-white">You do not own a Discord server</p>
</div>
)}
</>
);
}

View File

@@ -0,0 +1,28 @@
import Link from 'next/link';
import GuildsList from './guilds';
import { auth } from '@master-bot/auth';
import { redirect } from 'next/navigation';
export default async function DashboardIndexPage() {
const session = await auth();
if (!session) {
redirect('/');
}
return (
<div className="bg-slate-900 h-screen">
<header className="py-4 px-5">
<Link href="/">
<h3 className="text-white hover:underline">Go back</h3>
</Link>
</header>
<main className="flex flex-col items-center justify-center mx-80">
<h1 className="text-white text-5xl font-semibold mb-10">
Select a guild
</h1>
<GuildsList />
</main>
</div>
);
}

View File

@@ -0,0 +1,39 @@
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import '~/styles/globals.css';
import { TRPCReactProvider } from './providers';
import { headers } from 'next/headers';
import { ThemeProvider } from '~/components/theme-provider';
import { Toaster } from '~/components/ui/toaster';
const fontSans = Inter({
subsets: ['latin'],
variable: '--font-sans'
});
export const metadata: Metadata = {
title: 'Master Bot Dashboard',
description: 'Master bot monorepo with shared backend for web & bot apps'
};
export default function Layout(props: { children: React.ReactNode }) {
return (
<html lang="en">
<body
className={[
'font-sans dark:bg-slate-900 bg-white h-screen',
fontSans.variable
].join(' ')}
>
<TRPCReactProvider headers={headers()}>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<>{props.children}</>
<Toaster />
</ThemeProvider>
</TRPCReactProvider>
</body>
</html>
);
}

View File

@@ -0,0 +1,16 @@
import HeaderButtons from '~/components/header-buttons';
import Logo from '~/components/logo';
export default function HomePage() {
return (
<div>
<header className="p-40 py-10 flex justify-between">
<div>
<Logo />
</div>
<HeaderButtons />
</header>
<main></main>
</div>
);
}

View File

@@ -0,0 +1,65 @@
'use client';
import { useState } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { ReactQueryStreamedHydration } from '@tanstack/react-query-next-experimental';
import { loggerLink, unstable_httpBatchStreamLink } from '@trpc/client';
import superjson from 'superjson';
import { api } from '~/utils/api';
const getBaseUrl = () => {
if (typeof window !== 'undefined') return ''; // browser should use relative url
// if (env.VERCEL_URL) return env.VERCEL_URL; // SSR should use vercel url
return `http://localhost:3000`; // dev SSR should use localhost
};
export function TRPCReactProvider(props: {
children: React.ReactNode;
headers?: Headers;
}) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 1000
}
}
})
);
const [trpcClient] = useState(() =>
api.createClient({
transformer: superjson,
links: [
loggerLink({
enabled: opts =>
process.env.NODE_ENV === 'development' ||
(opts.direction === 'down' && opts.result instanceof Error)
}),
unstable_httpBatchStreamLink({
url: `${getBaseUrl()}/api/trpc`,
headers() {
const headers = new Map(props.headers);
headers.set('x-trpc-source', 'nextjs-react');
return Object.fromEntries(headers);
}
})
]
})
);
return (
<api.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
<ReactQueryStreamedHydration transformer={superjson}>
{props.children}
</ReactQueryStreamedHydration>
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
</api.Provider>
);
}

View File

@@ -0,0 +1,24 @@
import type { ComponentProps } from 'react';
import type { OAuthProviders } from '@master-bot/auth';
import { CSRF_experimental } from '@master-bot/auth';
export function SignIn({
provider,
...props
}: { provider: OAuthProviders } & ComponentProps<'button'>) {
return (
<form action={`/api/auth/signin/${provider}`} method="post">
<button {...props} />
<CSRF_experimental />
</form>
);
}
export function SignOut(props: ComponentProps<'button'>) {
return (
<form action="/api/auth/signout" method="post">
<button {...props} />
<CSRF_experimental />
</form>
);
}

View File

@@ -0,0 +1,77 @@
import { auth } from '@master-bot/auth';
import { Button } from '~/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '~/components/ui/dropdown';
import Image from 'next/image';
import Link from 'next/link';
import { Server } from 'lucide-react';
import { SignIn, SignOut } from '~/components/auth';
import { ModeToggle } from '~/components/theme-toggle';
export default async function HeaderButtons() {
const session = await auth();
return (
<div className="flex items-center justify-between gap-5">
<a
href="https://github.com/galnir/Master-Bot"
target="_blank"
rel="noopener noreferrer"
>
<Button>Code on Github</Button>
</a>
{session ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div className="flex items-center gap-3 hover:cursor-pointer">
<Image
src={`https://cdn.discordapp.com/avatars/${session.user.discordId}/${session.user.image}.webp?size=512`}
className="h-8 w-8 rounded-full"
width={32}
height={32}
alt="user avatar"
/>
<h1 className="dark:text-white text-black">
{session.user.name}
</h1>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" sideOffset={12}>
<DropdownMenuGroup>
<DropdownMenuItem>
<Link
href="/dashboard"
className="w-full h-full flex items-center"
>
<Server />
<span className="ml-2">My Servers</span>
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator className="bg-gray-400" />
<DropdownMenuItem>
<div className="w-56">
<SignOut className="w-full text-left">Sign out</SignOut>
</div>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
) : (
<SignIn
provider="discord"
className="rounded-full bg-blue-600 px-10 py-3 font-semibold text-white no-underline transition hover:bg-blue-700"
>
Sign in with Discord
</SignIn>
)}
<ModeToggle />
</div>
);
}

View File

@@ -0,0 +1,20 @@
export default function Logo({
size = 'large'
}: {
size?: 'small' | 'medium' | 'large';
}) {
return (
<div
className={`font-bold text-transparent w-max bg-clip-text bg-gradient-to-r from-red-600 to-amber-500 ${
size === 'small'
? 'text-3xl'
: size === 'medium'
? 'text-4xl'
: 'text-6xl'
}
}`}
>
<span>Master Bot</span>
</div>
);
}

View File

@@ -0,0 +1,9 @@
'use client';
import * as React from 'react';
import { ThemeProvider as NextThemesProvider } from 'next-themes';
import { type ThemeProviderProps } from 'next-themes/dist/types';
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}

View File

@@ -0,0 +1,40 @@
'use client';
import * as React from 'react';
import { Moon, Sun } from 'lucide-react';
import { useTheme } from 'next-themes';
import { Button } from '~/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '~/components/ui/dropdown';
export function ModeToggle() {
const { setTheme } = useTheme();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme('light')}>
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('dark')}>
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('system')}>
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,56 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '~/lib/utils';
const buttonVariants = cva(
'inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive:
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline:
'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline'
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10'
}
},
defaultVariants: {
variant: 'default',
size: 'default'
}
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = 'Button';
export { Button, buttonVariants };

View File

@@ -0,0 +1,200 @@
'use client';
import * as React from 'react';
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { Check, ChevronRight, Circle } from 'lucide-react';
import { cn } from '~/lib/utils';
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent',
inset && 'pl-8',
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
));
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
inset && 'pl-8',
className
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
'px-2 py-1.5 text-sm font-semibold',
inset && 'pl-8',
className
)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn('ml-auto text-xs tracking-widest opacity-60', className)}
{...props}
/>
);
};
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup
};

View File

@@ -0,0 +1,121 @@
'use client';
import * as React from 'react';
import * as SelectPrimitive from '@radix-ui/react-select';
import { Check, ChevronDown } from 'lucide-react';
import { cn } from '~/lib/utils';
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-transparent px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = 'popper', ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
'relative z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className
)}
position={position}
{...props}
>
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]'
)}
>
{children}
</SelectPrimitive.Viewport>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn('py-1.5 pl-8 pr-2 text-sm font-semibold', className)}
{...props}
/>
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
{...props}
/>
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator
};

View File

@@ -0,0 +1,29 @@
'use client';
import * as React from 'react';
import * as SwitchPrimitives from '@radix-ui/react-switch';
import { cn } from '~/lib/utils';
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
'peer inline-flex h-[24px] w-[44px] shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
'pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0'
)}
/>
</SwitchPrimitives.Root>
));
Switch.displayName = SwitchPrimitives.Root.displayName;
export { Switch };

View File

@@ -0,0 +1,127 @@
import * as React from 'react';
import * as ToastPrimitives from '@radix-ui/react-toast';
import { cva, type VariantProps } from 'class-variance-authority';
import { X } from 'lucide-react';
import { cn } from '~/lib/utils';
const ToastProvider = ToastPrimitives.Provider;
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
'fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]',
className
)}
{...props}
/>
));
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
const toastVariants = cva(
'group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full',
{
variants: {
variant: {
default: 'border bg-background',
destructive:
'destructive group border-destructive bg-destructive text-destructive-foreground'
}
},
defaultVariants: {
variant: 'default'
}
}
);
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
);
});
Toast.displayName = ToastPrimitives.Root.displayName;
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
'inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive',
className
)}
{...props}
/>
));
ToastAction.displayName = ToastPrimitives.Action.displayName;
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
'absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600',
className
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
));
ToastClose.displayName = ToastPrimitives.Close.displayName;
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn('text-sm font-semibold', className)}
{...props}
/>
));
ToastTitle.displayName = ToastPrimitives.Title.displayName;
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn('text-sm opacity-90', className)}
{...props}
/>
));
ToastDescription.displayName = ToastPrimitives.Description.displayName;
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
type ToastActionElement = React.ReactElement<typeof ToastAction>;
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction
};

View File

@@ -0,0 +1,35 @@
'use client';
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport
} from './toast';
import { useToast } from './use-toast';
export function Toaster() {
const { toasts } = useToast();
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
);
})}
<ToastViewport />
</ToastProvider>
);
}

View File

@@ -0,0 +1,190 @@
// Inspired by react-hot-toast library
import * as React from 'react';
import type { ToastActionElement, ToastProps } from './toast';
const TOAST_LIMIT = 1;
const TOAST_REMOVE_DELAY = 1000000;
type ToasterToast = ToastProps & {
id: string;
title?: React.ReactNode;
description?: React.ReactNode;
action?: ToastActionElement;
};
const actionTypes = {
ADD_TOAST: 'ADD_TOAST',
UPDATE_TOAST: 'UPDATE_TOAST',
DISMISS_TOAST: 'DISMISS_TOAST',
REMOVE_TOAST: 'REMOVE_TOAST'
} as const;
let count = 0;
function genId() {
count = (count + 1) % Number.MAX_VALUE;
return count.toString();
}
type ActionType = typeof actionTypes;
type Action =
| {
type: ActionType['ADD_TOAST'];
toast: ToasterToast;
}
| {
type: ActionType['UPDATE_TOAST'];
toast: Partial<ToasterToast>;
}
| {
type: ActionType['DISMISS_TOAST'];
toastId?: ToasterToast['id'];
}
| {
type: ActionType['REMOVE_TOAST'];
toastId?: ToasterToast['id'];
};
interface State {
toasts: ToasterToast[];
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return;
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId);
dispatch({
type: 'REMOVE_TOAST',
toastId: toastId
});
}, TOAST_REMOVE_DELAY);
toastTimeouts.set(toastId, timeout);
};
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case 'ADD_TOAST':
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT)
};
case 'UPDATE_TOAST':
return {
...state,
toasts: state.toasts.map(t =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
)
};
case 'DISMISS_TOAST': {
const { toastId } = action;
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId);
} else {
state.toasts.forEach(toast => {
addToRemoveQueue(toast.id);
});
}
return {
...state,
toasts: state.toasts.map(t =>
t.id === toastId || toastId === undefined
? {
...t,
open: false
}
: t
)
};
}
case 'REMOVE_TOAST':
if (action.toastId === undefined) {
return {
...state,
toasts: []
};
}
return {
...state,
toasts: state.toasts.filter(t => t.id !== action.toastId)
};
}
};
// eslint-disable-next-line @typescript-eslint/array-type
const listeners: Array<(state: State) => void> = [];
let memoryState: State = { toasts: [] };
function dispatch(action: Action) {
memoryState = reducer(memoryState, action);
listeners.forEach(listener => {
listener(memoryState);
});
}
type Toast = Omit<ToasterToast, 'id'>;
function toast({ ...props }: Toast) {
const id = genId();
const update = (props: ToasterToast) =>
dispatch({
type: 'UPDATE_TOAST',
toast: { ...props, id }
});
const dismiss = () => dispatch({ type: 'DISMISS_TOAST', toastId: id });
dispatch({
type: 'ADD_TOAST',
toast: {
...props,
id,
open: true,
onOpenChange: open => {
if (!open) dismiss();
}
}
});
return {
id: id,
dismiss,
update
};
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState);
React.useEffect(() => {
listeners.push(setState);
return () => {
const index = listeners.indexOf(setState);
if (index > -1) {
listeners.splice(index, 1);
}
};
}, [state]);
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: 'DISMISS_TOAST', toastId })
};
}
export { useToast, toast };

View File

@@ -0,0 +1,31 @@
import { createEnv } from '@t3-oss/env-nextjs';
import { z } from 'zod';
export const env = createEnv({
/**
* Specify your server-side environment variables schema here. This way you can ensure the app isn't
* built with invalid env vars.
*/
server: {
DATABASE_URL: z.string().url(),
DISCORD_TOKEN: z.string(),
DISCORD_CLIENT_ID: z.string()
},
/**
* Specify your client-side environment variables schema here.
* For them to be exposed to the client, prefix them with `NEXT_PUBLIC_`.
*/
client: {
NEXT_PUBLIC_INVITE_URL: z.string().url()
},
/**
* Destructure all variables from `process.env` to make sure they aren't tree-shaken away.
*/
runtimeEnv: {
DATABASE_URL: process.env.DATABASE_URL,
DISCORD_TOKEN: process.env.DISCORD_TOKEN,
DISCORD_CLIENT_ID: process.env.DISCORD_CLIENT_ID,
NEXT_PUBLIC_INVITE_URL: process.env.NEXT_PUBLIC_INVITE_URL
},
skipValidation: !!process.env.CI || !!process.env.SKIP_ENV_VALIDATION
});

View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@@ -0,0 +1,99 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--ring: 240 5% 64.9%;
--radius: 0.5rem;
}
.dark {
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 85.7% 97.3%;
--ring: 240 3.7% 15.9%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
font-feature-settings:
'rlig' 1,
'calt' 1;
}
}
@layer utilities {
.step {
counter-increment: step;
}
.step:before {
@apply absolute w-9 h-9 bg-muted rounded-full font-mono font-medium text-center text-base inline-flex items-center justify-center -indent-px border-4 border-background;
@apply ml-[-50px] mt-[-4px];
content: counter(step);
}
}
@media (max-width: 640px) {
.container {
@apply px-4;
}
}

View File

@@ -0,0 +1,6 @@
import type { AppRouter } from '@master-bot/api';
import { createTRPCReact } from '@trpc/react-query';
export const api = createTRPCReact<AppRouter>();
export { type RouterInputs, type RouterOutputs } from '@master-bot/api';

View File

@@ -0,0 +1,71 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: ['class'],
content: ['src/app/**/*.{ts,tsx}', 'src/components/**/*.{ts,tsx}'],
theme: {
container: {
center: true,
padding: '2rem',
screens: {
'2xl': '1400px'
}
},
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))'
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))'
},
destructive: {
DEFAULT: 'hsl(var(--destructive) / <alpha-value>)',
foreground: 'hsl(var(--destructive-foreground) / <alpha-value>)'
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))'
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))'
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))'
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))'
}
},
borderRadius: {
lg: `var(--radius)`,
md: `calc(var(--radius) - 2px)`,
sm: 'calc(var(--radius) - 4px)'
},
keyframes: {
'accordion-down': {
from: { height: 0 },
to: { height: 'var(--radix-accordion-content-height)' }
},
'accordion-up': {
from: { height: 'var(--radix-accordion-content-height)' },
to: { height: 0 }
}
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out'
}
}
},
plugins: [require('tailwindcss-animate')]
};

View File

@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"~/*": ["./src/*"]
},
"plugins": [{ "name": "next" }],
"strict": true
},
"include": ["next-env.d.ts", "src", "*.ts", "*.mjs", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}