initial commit: fork
This commit is contained in:
28
apps/dashboard/README.md
Normal file
28
apps/dashboard/README.md
Normal 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.
|
||||
16
apps/dashboard/components.json
Normal file
16
apps/dashboard/components.json
Normal 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
5
apps/dashboard/next-env.d.ts
vendored
Normal 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.
|
||||
21
apps/dashboard/next.config.mjs
Normal file
21
apps/dashboard/next.config.mjs
Normal 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;
|
||||
66
apps/dashboard/package.json
Normal file
66
apps/dashboard/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
2
apps/dashboard/postcss.config.cjs
Normal file
2
apps/dashboard/postcss.config.cjs
Normal file
@@ -0,0 +1,2 @@
|
||||
// @ts-expect-error - No types for postcss
|
||||
module.exports = require('@master-bot/tailwind-config/postcss');
|
||||
BIN
apps/dashboard/public/favicon.ico
Normal file
BIN
apps/dashboard/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 101 KiB |
13
apps/dashboard/public/t3-icon.svg
Normal file
13
apps/dashboard/public/t3-icon.svg
Normal 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 |
6
apps/dashboard/src/app/api/auth/[...nextauth]/route.ts
Normal file
6
apps/dashboard/src/app/api/auth/[...nextauth]/route.ts
Normal 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";
|
||||
38
apps/dashboard/src/app/api/trpc/[trpc]/route.ts
Normal file
38
apps/dashboard/src/app/api/trpc/[trpc]/route.ts
Normal 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 };
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export default function Loading() {
|
||||
// You can add any UI inside Loading, including a Skeleton.
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
});
|
||||
})
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
46
apps/dashboard/src/app/dashboard/[server_id]/layout.tsx
Normal file
46
apps/dashboard/src/app/dashboard/[server_id]/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
7
apps/dashboard/src/app/dashboard/[server_id]/page.tsx
Normal file
7
apps/dashboard/src/app/dashboard/[server_id]/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
export default function ServerIndexPage() {
|
||||
return (
|
||||
<div>
|
||||
<h2>Guild index page</h2>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
apps/dashboard/src/app/dashboard/[server_id]/sidebar.tsx
Normal file
38
apps/dashboard/src/app/dashboard/[server_id]/sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export default function Loading() {
|
||||
// You can add any UI inside Loading, including a Skeleton.
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
});
|
||||
})
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
57
apps/dashboard/src/app/dashboard/guilds.tsx
Normal file
57
apps/dashboard/src/app/dashboard/guilds.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
28
apps/dashboard/src/app/dashboard/page.tsx
Normal file
28
apps/dashboard/src/app/dashboard/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
apps/dashboard/src/app/layout.tsx
Normal file
39
apps/dashboard/src/app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
apps/dashboard/src/app/page.tsx
Normal file
16
apps/dashboard/src/app/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
65
apps/dashboard/src/app/providers.tsx
Normal file
65
apps/dashboard/src/app/providers.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
apps/dashboard/src/components/auth.tsx
Normal file
24
apps/dashboard/src/components/auth.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
77
apps/dashboard/src/components/header-buttons.tsx
Normal file
77
apps/dashboard/src/components/header-buttons.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
apps/dashboard/src/components/logo.tsx
Normal file
20
apps/dashboard/src/components/logo.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
9
apps/dashboard/src/components/theme-provider.tsx
Normal file
9
apps/dashboard/src/components/theme-provider.tsx
Normal 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>;
|
||||
}
|
||||
40
apps/dashboard/src/components/theme-toggle.tsx
Normal file
40
apps/dashboard/src/components/theme-toggle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
apps/dashboard/src/components/ui/button.tsx
Normal file
56
apps/dashboard/src/components/ui/button.tsx
Normal 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 };
|
||||
200
apps/dashboard/src/components/ui/dropdown.tsx
Normal file
200
apps/dashboard/src/components/ui/dropdown.tsx
Normal 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
|
||||
};
|
||||
121
apps/dashboard/src/components/ui/select.tsx
Normal file
121
apps/dashboard/src/components/ui/select.tsx
Normal 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
|
||||
};
|
||||
29
apps/dashboard/src/components/ui/switch.tsx
Normal file
29
apps/dashboard/src/components/ui/switch.tsx
Normal 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 };
|
||||
127
apps/dashboard/src/components/ui/toast.tsx
Normal file
127
apps/dashboard/src/components/ui/toast.tsx
Normal 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
|
||||
};
|
||||
35
apps/dashboard/src/components/ui/toaster.tsx
Normal file
35
apps/dashboard/src/components/ui/toaster.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
190
apps/dashboard/src/components/ui/use-toast.ts
Normal file
190
apps/dashboard/src/components/ui/use-toast.ts
Normal 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 };
|
||||
31
apps/dashboard/src/env.mjs
Normal file
31
apps/dashboard/src/env.mjs
Normal 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
|
||||
});
|
||||
6
apps/dashboard/src/lib/utils.ts
Normal file
6
apps/dashboard/src/lib/utils.ts
Normal 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));
|
||||
}
|
||||
99
apps/dashboard/src/styles/globals.css
Normal file
99
apps/dashboard/src/styles/globals.css
Normal 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;
|
||||
}
|
||||
}
|
||||
6
apps/dashboard/src/utils/api.ts
Normal file
6
apps/dashboard/src/utils/api.ts
Normal 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';
|
||||
71
apps/dashboard/tailwind.config.js
Normal file
71
apps/dashboard/tailwind.config.js
Normal 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')]
|
||||
};
|
||||
13
apps/dashboard/tsconfig.json
Normal file
13
apps/dashboard/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user